chore: adiciona .gitignore e commit.command
This commit is contained in:
+322
@@ -0,0 +1,322 @@
|
||||
//
|
||||
// AppFeature.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/26/25.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import SwiftUI
|
||||
|
||||
@Reducer
|
||||
struct AppFeature {
|
||||
private enum CancelID {
|
||||
case modelMissingFlash
|
||||
}
|
||||
|
||||
enum ActiveTab: Equatable {
|
||||
case settings
|
||||
case remappings
|
||||
case history
|
||||
case about
|
||||
}
|
||||
|
||||
@ObservableState
|
||||
struct State {
|
||||
var transcription: TranscriptionFeature.State = .init()
|
||||
var settings: SettingsFeature.State = .init()
|
||||
var history: HistoryFeature.State = .init()
|
||||
var activeTab: ActiveTab = .settings
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
|
||||
// Permission state
|
||||
var microphonePermission: PermissionStatus = .notDetermined
|
||||
var accessibilityPermission: PermissionStatus = .notDetermined
|
||||
var inputMonitoringPermission: PermissionStatus = .notDetermined
|
||||
}
|
||||
|
||||
enum Action: BindableAction {
|
||||
case binding(BindingAction<State>)
|
||||
case transcription(TranscriptionFeature.Action)
|
||||
case settings(SettingsFeature.Action)
|
||||
case history(HistoryFeature.Action)
|
||||
case setActiveTab(ActiveTab)
|
||||
case task
|
||||
case pasteLastTranscript
|
||||
|
||||
// Permission actions
|
||||
case checkPermissions
|
||||
case permissionsUpdated(mic: PermissionStatus, acc: PermissionStatus, input: PermissionStatus)
|
||||
case appActivated
|
||||
case modelStatusEvaluated(Bool)
|
||||
}
|
||||
|
||||
@Dependency(\.keyEventMonitor) var keyEventMonitor
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
@Dependency(\.transcription) var transcription
|
||||
@Dependency(\.permissions) var permissions
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
BindingReducer()
|
||||
|
||||
Scope(state: \.transcription, action: \.transcription) {
|
||||
TranscriptionFeature()
|
||||
}
|
||||
|
||||
Scope(state: \.settings, action: \.settings) {
|
||||
SettingsFeature()
|
||||
}
|
||||
|
||||
Scope(state: \.history, action: \.history) {
|
||||
HistoryFeature()
|
||||
}
|
||||
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .binding:
|
||||
return .none
|
||||
|
||||
case .task:
|
||||
return .merge(
|
||||
startPasteLastTranscriptMonitoring(),
|
||||
ensureSelectedModelReadiness(),
|
||||
startPermissionMonitoring()
|
||||
)
|
||||
|
||||
case .pasteLastTranscript:
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
guard let lastTranscript = transcriptionHistory.history.first?.text else {
|
||||
return .none
|
||||
}
|
||||
return .run { _ in
|
||||
await pasteboard.paste(lastTranscript)
|
||||
}
|
||||
|
||||
case .transcription(.modelMissing):
|
||||
HexLog.app.notice("Model missing - activating app and switching to settings")
|
||||
state.activeTab = .settings
|
||||
state.settings.shouldFlashModelSection = true
|
||||
return .run { send in
|
||||
await MainActor.run {
|
||||
HexLog.app.notice("Activating app for model missing")
|
||||
NotificationCenter.default.post(name: .presentSettingsWindow, object: nil)
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
await send(.settings(.set(\.shouldFlashModelSection, false)))
|
||||
}
|
||||
.cancellable(id: CancelID.modelMissingFlash, cancelInFlight: true)
|
||||
|
||||
case .transcription:
|
||||
return .none
|
||||
|
||||
case .settings(.requestMicrophone):
|
||||
return .run { send in
|
||||
_ = await permissions.requestMicrophone()
|
||||
await send(.checkPermissions)
|
||||
}
|
||||
|
||||
case .settings(.requestAccessibility):
|
||||
return .run { send in
|
||||
await permissions.requestAccessibility()
|
||||
// Poll for status change (macOS doesn't provide callback)
|
||||
for _ in 0..<10 {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
await send(.checkPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
case .settings(.requestInputMonitoring):
|
||||
return .run { send in
|
||||
_ = await permissions.requestInputMonitoring()
|
||||
for _ in 0..<10 {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
await send(.checkPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
case .settings:
|
||||
return .none
|
||||
|
||||
case .history(.navigateToSettings):
|
||||
state.activeTab = .settings
|
||||
return .none
|
||||
case .history:
|
||||
return .none
|
||||
case let .setActiveTab(tab):
|
||||
state.activeTab = tab
|
||||
return .none
|
||||
|
||||
// Permission handling
|
||||
case .checkPermissions:
|
||||
return .run { send in
|
||||
async let mic = permissions.microphoneStatus()
|
||||
async let acc = permissions.accessibilityStatus()
|
||||
async let input = permissions.inputMonitoringStatus()
|
||||
await send(.permissionsUpdated(mic: mic, acc: acc, input: input))
|
||||
}
|
||||
|
||||
case let .permissionsUpdated(mic, acc, input):
|
||||
state.microphonePermission = mic
|
||||
state.accessibilityPermission = acc
|
||||
state.inputMonitoringPermission = input
|
||||
return .none
|
||||
|
||||
case .appActivated:
|
||||
// App became active - re-check permissions
|
||||
return .send(.checkPermissions)
|
||||
|
||||
case .modelStatusEvaluated:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPasteLastTranscriptMonitoring() -> Effect<Action> {
|
||||
.run { send in
|
||||
@Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
let token = keyEventMonitor.handleKeyEvent { keyEvent in
|
||||
// Skip if user is setting a hotkey
|
||||
if isSettingPasteLastTranscriptHotkey {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this matches the paste last transcript hotkey
|
||||
guard let pasteHotkey = hexSettings.pasteLastTranscriptHotkey,
|
||||
let key = keyEvent.key,
|
||||
key == pasteHotkey.key,
|
||||
keyEvent.modifiers.matchesExactly(pasteHotkey.modifiers) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trigger paste action - use MainActor to avoid escaping send
|
||||
MainActor.assumeIsolated {
|
||||
send(.pasteLastTranscript)
|
||||
}
|
||||
return true // Intercept the key event
|
||||
}
|
||||
|
||||
defer { token.cancel() }
|
||||
|
||||
await withTaskCancellationHandler {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
} onCancel: {
|
||||
token.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureSelectedModelReadiness() -> Effect<Action> {
|
||||
.run { send in
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
let selectedModel = hexSettings.selectedModel
|
||||
guard !selectedModel.isEmpty else {
|
||||
await send(.modelStatusEvaluated(false))
|
||||
return
|
||||
}
|
||||
let isReady = await transcription.isModelDownloaded(selectedModel)
|
||||
$modelBootstrapState.withLock { state in
|
||||
state.modelIdentifier = selectedModel
|
||||
if state.modelDisplayName?.isEmpty ?? true {
|
||||
state.modelDisplayName = selectedModel
|
||||
}
|
||||
state.isModelReady = isReady
|
||||
if isReady {
|
||||
state.lastError = nil
|
||||
state.progress = 1
|
||||
} else {
|
||||
state.progress = 0
|
||||
}
|
||||
}
|
||||
await send(.modelStatusEvaluated(isReady))
|
||||
}
|
||||
}
|
||||
|
||||
private func startPermissionMonitoring() -> Effect<Action> {
|
||||
.run { send in
|
||||
// Initial check on app launch
|
||||
await send(.checkPermissions)
|
||||
|
||||
// Monitor app activation events
|
||||
for await activation in permissions.observeAppActivation() {
|
||||
if case .didBecomeActive = activation {
|
||||
await send(.appActivated)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct AppView: View {
|
||||
@Bindable var store: StoreOf<AppFeature>
|
||||
@State private var columnVisibility = NavigationSplitViewVisibility.automatic
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
List(selection: $store.activeTab) {
|
||||
Button {
|
||||
store.send(.setActiveTab(.settings))
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.settings)
|
||||
|
||||
Button {
|
||||
store.send(.setActiveTab(.remappings))
|
||||
} label: {
|
||||
Label("Transforms", systemImage: "text.badge.plus")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.remappings)
|
||||
|
||||
Button {
|
||||
store.send(.setActiveTab(.history))
|
||||
} label: {
|
||||
Label("History", systemImage: "clock")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.history)
|
||||
|
||||
Button {
|
||||
store.send(.setActiveTab(.about))
|
||||
} label: {
|
||||
Label("About", systemImage: "info.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.about)
|
||||
}
|
||||
} detail: {
|
||||
switch store.state.activeTab {
|
||||
case .settings:
|
||||
SettingsView(
|
||||
store: store.scope(state: \.settings, action: \.settings),
|
||||
microphonePermission: store.microphonePermission,
|
||||
accessibilityPermission: store.accessibilityPermission,
|
||||
inputMonitoringPermission: store.inputMonitoringPermission
|
||||
)
|
||||
.navigationTitle("Settings")
|
||||
case .remappings:
|
||||
WordRemappingsView(store: store.scope(state: \.settings, action: \.settings))
|
||||
.navigationTitle("Transforms")
|
||||
case .history:
|
||||
HistoryView(store: store.scope(state: \.history, action: \.history))
|
||||
.navigationTitle("History")
|
||||
case .about:
|
||||
AboutView(store: store.scope(state: \.settings, action: \.settings))
|
||||
.navigationTitle("About")
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
private let historyLogger = HexLog.history
|
||||
|
||||
// MARK: - Date Extensions
|
||||
|
||||
extension Date {
|
||||
func relativeFormatted() -> String {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
if calendar.isDateInToday(self) {
|
||||
return "Today"
|
||||
} else if calendar.isDateInYesterday(self) {
|
||||
return "Yesterday"
|
||||
} else if let daysAgo = calendar.dateComponents([.day], from: self, to: now).day, daysAgo < 7 {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE" // Day of week
|
||||
return formatter.string(from: self)
|
||||
} else {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
extension SharedReaderKey
|
||||
where Self == FileStorageKey<TranscriptionHistory>.Default
|
||||
{
|
||||
static var transcriptionHistory: Self {
|
||||
Self[
|
||||
.fileStorage(.transcriptionHistoryURL),
|
||||
default: .init()
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Storage Migration
|
||||
|
||||
extension URL {
|
||||
static var transcriptionHistoryURL: URL {
|
||||
get {
|
||||
URL.hexMigratedFileURL(named: "transcription_history.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AudioPlayerController: NSObject, AVAudioPlayerDelegate {
|
||||
private var player: AVAudioPlayer?
|
||||
private let (playbackFinishedStream, playbackFinishedContinuation) = AsyncStream<Void>.makeStream()
|
||||
|
||||
func play(url: URL) throws {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.delegate = self
|
||||
self.player = player
|
||||
player.play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.stop()
|
||||
player = nil
|
||||
finishPlayback()
|
||||
}
|
||||
|
||||
func waitForPlaybackToFinish() async {
|
||||
for await _ in playbackFinishedStream {}
|
||||
}
|
||||
|
||||
// AVAudioPlayerDelegate method
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
guard self.player === player else { return }
|
||||
self.player = nil
|
||||
finishPlayback()
|
||||
}
|
||||
|
||||
private func finishPlayback() {
|
||||
playbackFinishedContinuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - History Feature
|
||||
|
||||
@Reducer
|
||||
struct HistoryFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
var playingTranscriptID: UUID?
|
||||
var playbackID: UUID?
|
||||
var audioPlayerController: AudioPlayerController?
|
||||
|
||||
mutating func stopAudioPlayback() {
|
||||
audioPlayerController?.stop()
|
||||
audioPlayerController = nil
|
||||
playingTranscriptID = nil
|
||||
playbackID = nil
|
||||
}
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case playTranscript(UUID)
|
||||
case stopPlayback
|
||||
case copyToClipboard(String)
|
||||
case deleteTranscript(UUID)
|
||||
case deleteAllTranscripts
|
||||
case confirmDeleteAll
|
||||
case playbackFinished(UUID)
|
||||
case navigateToSettings
|
||||
}
|
||||
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
@Dependency(\.transcriptPersistence) var transcriptPersistence
|
||||
|
||||
private func deleteAudioEffect(for transcripts: [Transcript]) -> Effect<Action> {
|
||||
.run { [transcriptPersistence] _ in
|
||||
for transcript in transcripts {
|
||||
try? await transcriptPersistence.deleteAudio(transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case let .playTranscript(id):
|
||||
if state.playingTranscriptID == id {
|
||||
// Stop playback if tapping the same transcript
|
||||
state.stopAudioPlayback()
|
||||
return .none
|
||||
}
|
||||
|
||||
// Stop any existing playback
|
||||
state.stopAudioPlayback()
|
||||
|
||||
// Find the transcript and play its audio
|
||||
guard let transcript = state.transcriptionHistory.history.first(where: { $0.id == id }) else {
|
||||
return .none
|
||||
}
|
||||
|
||||
do {
|
||||
let controller = AudioPlayerController()
|
||||
try controller.play(url: transcript.audioPath)
|
||||
let playbackID = UUID()
|
||||
|
||||
state.audioPlayerController = controller
|
||||
state.playingTranscriptID = id
|
||||
state.playbackID = playbackID
|
||||
|
||||
return .run { send in
|
||||
await controller.waitForPlaybackToFinish()
|
||||
await send(.playbackFinished(playbackID))
|
||||
}
|
||||
} catch {
|
||||
historyLogger.error("Failed to play audio: \(error.localizedDescription)")
|
||||
return .none
|
||||
}
|
||||
|
||||
case .stopPlayback:
|
||||
state.stopAudioPlayback()
|
||||
return .none
|
||||
|
||||
case let .playbackFinished(playbackID):
|
||||
guard state.playbackID == playbackID else { return .none }
|
||||
state.stopAudioPlayback()
|
||||
return .none
|
||||
|
||||
case let .copyToClipboard(text):
|
||||
return .run { [pasteboard] _ in
|
||||
await pasteboard.copy(text)
|
||||
}
|
||||
|
||||
case let .deleteTranscript(id):
|
||||
guard let index = state.transcriptionHistory.history.firstIndex(where: { $0.id == id }) else {
|
||||
return .none
|
||||
}
|
||||
|
||||
let transcript = state.transcriptionHistory.history[index]
|
||||
|
||||
if state.playingTranscriptID == id {
|
||||
state.stopAudioPlayback()
|
||||
}
|
||||
|
||||
_ = state.$transcriptionHistory.withLock { history in
|
||||
history.history.remove(at: index)
|
||||
}
|
||||
|
||||
return deleteAudioEffect(for: [transcript])
|
||||
|
||||
case .deleteAllTranscripts:
|
||||
return .send(.confirmDeleteAll)
|
||||
|
||||
case .confirmDeleteAll:
|
||||
let transcripts = state.transcriptionHistory.history
|
||||
state.stopAudioPlayback()
|
||||
|
||||
state.$transcriptionHistory.withLock { history in
|
||||
history.history.removeAll()
|
||||
}
|
||||
|
||||
return deleteAudioEffect(for: transcripts)
|
||||
|
||||
case .navigateToSettings:
|
||||
// This will be handled by the parent reducer
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TranscriptView: View {
|
||||
let transcript: Transcript
|
||||
let isPlaying: Bool
|
||||
let onPlay: () -> Void
|
||||
let onCopy: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(transcript.text)
|
||||
.font(.body)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.trailing, 40) // Space for buttons
|
||||
.padding(12)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
// App icon and name
|
||||
if let bundleID = transcript.sourceAppBundleID,
|
||||
let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {
|
||||
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
|
||||
.resizable()
|
||||
.frame(width: 14, height: 14)
|
||||
if let appName = transcript.sourceAppName {
|
||||
Text(appName)
|
||||
}
|
||||
Text("•")
|
||||
}
|
||||
|
||||
Image(systemName: "clock")
|
||||
Text(transcript.timestamp.relativeFormatted())
|
||||
Text("•")
|
||||
Text(transcript.timestamp.formatted(date: .omitted, time: .shortened))
|
||||
Text("•")
|
||||
Text(String(format: "%.1fs", transcript.duration))
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
onCopy()
|
||||
showCopyAnimation()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: showCopied ? "checkmark" : "doc.on.doc.fill")
|
||||
if showCopied {
|
||||
Text("Copied").font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(showCopied ? .green : .secondary)
|
||||
.help("Copy to clipboard")
|
||||
|
||||
Button(action: onPlay) {
|
||||
Image(systemName: isPlaying ? "stop.fill" : "play.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(isPlaying ? .blue : .secondary)
|
||||
.help(isPlaying ? "Stop playback" : "Play audio")
|
||||
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "trash.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.help("Delete transcript")
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
.frame(height: 20)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.5))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.onDisappear {
|
||||
// Clean up any running task when view disappears
|
||||
copyTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@State private var showCopied = false
|
||||
@State private var copyTask: Task<Void, Error>?
|
||||
|
||||
private func showCopyAnimation() {
|
||||
copyTask?.cancel()
|
||||
|
||||
copyTask = Task {
|
||||
withAnimation {
|
||||
showCopied = true
|
||||
}
|
||||
|
||||
try await Task.sleep(for: .seconds(1.5))
|
||||
|
||||
withAnimation {
|
||||
showCopied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TranscriptView(
|
||||
transcript: Transcript(timestamp: Date(), text: "Hello, world!", audioPath: URL(fileURLWithPath: "/Users/langton/Downloads/test.m4a"), duration: 1.0),
|
||||
isPlaying: false,
|
||||
onPlay: {},
|
||||
onCopy: {},
|
||||
onDelete: {}
|
||||
)
|
||||
}
|
||||
|
||||
struct HistoryView: View {
|
||||
@ObserveInjection var inject
|
||||
let store: StoreOf<HistoryFeature>
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !hexSettings.saveTranscriptionHistory {
|
||||
ContentUnavailableView {
|
||||
Label("History Disabled", systemImage: "clock.arrow.circlepath")
|
||||
} description: {
|
||||
Text("Transcription history is currently disabled.")
|
||||
} actions: {
|
||||
Button("Enable in Settings") {
|
||||
store.send(.navigateToSettings)
|
||||
}
|
||||
}
|
||||
} else if store.transcriptionHistory.history.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Transcriptions", systemImage: "text.bubble")
|
||||
} description: {
|
||||
Text("Your transcription history will appear here.")
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(store.transcriptionHistory.history) { transcript in
|
||||
TranscriptView(
|
||||
transcript: transcript,
|
||||
isPlaying: store.playingTranscriptID == transcript.id,
|
||||
onPlay: { store.send(.playTranscript(transcript.id)) },
|
||||
onCopy: { store.send(.copyToClipboard(transcript.text)) },
|
||||
onDelete: { store.send(.deleteTranscript(transcript.id)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.toolbar {
|
||||
Button(role: .destructive, action: { showingDeleteConfirmation = true }) {
|
||||
Label("Delete All", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.alert("Delete All Transcripts", isPresented: $showingDeleteConfirmation) {
|
||||
Button("Delete All", role: .destructive) {
|
||||
store.send(.confirmDeleteAll)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete all transcripts? This action cannot be undone.")
|
||||
}
|
||||
}
|
||||
}.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct WordRemappingsView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
@FocusState private var isScratchpadFocused: Bool
|
||||
@State private var activeSection: ModificationSection = .removals
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
previewSection
|
||||
wordRulesSection
|
||||
outputFormattingSection
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
}
|
||||
.onDisappear {
|
||||
store.send(.setRemappingScratchpadFocused(false))
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
private var previewSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Preview")
|
||||
.font(.headline)
|
||||
|
||||
GroupBox {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Input")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Type or speak a sample…", text: $store.remappingScratchpadText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($isScratchpadFocused)
|
||||
.onChange(of: isScratchpadFocused) { _, newValue in
|
||||
store.send(.setRemappingScratchpadFocused(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 27)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Output")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(previewText.isEmpty ? "—" : previewText)
|
||||
.frame(maxWidth: .infinity, minHeight: 22, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var wordRulesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Word rules")
|
||||
.font(.headline)
|
||||
|
||||
Picker("Rule type", selection: $activeSection) {
|
||||
ForEach(ModificationSection.allCases) { section in
|
||||
Text(section.title).tag(section)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 360)
|
||||
|
||||
switch activeSection {
|
||||
case .removals:
|
||||
removalsSection
|
||||
case .remappings:
|
||||
remappingsSection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var removalsSection: some View {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Remove matching words from every transcript.")
|
||||
.settingsCaption()
|
||||
Spacer()
|
||||
Toggle(
|
||||
"Enabled",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.wordRemovalsEnabled },
|
||||
set: { store.send(.setWordRemovalsEnabled($0)) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
Divider()
|
||||
removalsColumnHeaders
|
||||
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(store.hexSettings.wordRemovals.enumerated()), id: \.element.id) { index, removal in
|
||||
RemovalRow(removal: removalBinding(for: removal)) {
|
||||
store.send(.removeWordRemoval(removal.id))
|
||||
}
|
||||
if index < store.hexSettings.wordRemovals.count - 1 {
|
||||
Divider().padding(.leading, Layout.dividerLeadingPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
store.send(.addWordRemoval)
|
||||
} label: {
|
||||
Label("Add pattern", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var remappingsSection: some View {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Replace matching words in every transcript.")
|
||||
.settingsCaption()
|
||||
Divider()
|
||||
remappingsColumnHeaders
|
||||
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(store.hexSettings.wordRemappings.enumerated()), id: \.element.id) { index, remapping in
|
||||
RemappingRow(remapping: remappingBinding(for: remapping)) {
|
||||
store.send(.removeWordRemapping(remapping.id))
|
||||
}
|
||||
if index < store.hexSettings.wordRemappings.count - 1 {
|
||||
Divider().padding(.leading, Layout.dividerLeadingPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
store.send(.addWordRemapping)
|
||||
} label: {
|
||||
Label("Add replacement", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var outputFormattingSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Output formatting")
|
||||
.font(.headline)
|
||||
|
||||
GroupBox {
|
||||
HStack(spacing: 20) {
|
||||
Toggle(
|
||||
"Lowercase output",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.lowercaseTranscripts },
|
||||
set: { store.send(.setLowercaseTranscripts($0)) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Divider()
|
||||
.frame(height: 22)
|
||||
|
||||
Toggle(
|
||||
"Remove punctuation",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.removePunctuation },
|
||||
set: { store.send(.setRemovePunctuation($0)) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var removalsColumnHeaders: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("On")
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
Text("Pattern")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Spacer().frame(width: Layout.deleteColumnWidth)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
}
|
||||
|
||||
private var remappingsColumnHeaders: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("On")
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
Text("Match")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: Layout.arrowColumnWidth)
|
||||
Text("Replace")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Spacer().frame(width: Layout.deleteColumnWidth)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
}
|
||||
|
||||
private func removalBinding(for removal: WordRemoval) -> Binding<WordRemoval> {
|
||||
return Binding(
|
||||
get: {
|
||||
store.hexSettings.wordRemovals.first { $0.id == removal.id } ?? removal
|
||||
},
|
||||
set: { store.send(.updateWordRemoval($0)) }
|
||||
)
|
||||
}
|
||||
|
||||
private func remappingBinding(for remapping: WordRemapping) -> Binding<WordRemapping> {
|
||||
return Binding(
|
||||
get: {
|
||||
store.hexSettings.wordRemappings.first { $0.id == remapping.id } ?? remapping
|
||||
},
|
||||
set: { store.send(.updateWordRemapping($0)) }
|
||||
)
|
||||
}
|
||||
|
||||
private var previewText: String {
|
||||
var output = store.remappingScratchpadText
|
||||
if store.hexSettings.wordRemovalsEnabled {
|
||||
output = WordRemovalApplier.apply(output, removals: store.hexSettings.wordRemovals)
|
||||
}
|
||||
output = WordRemappingApplier.apply(output, remappings: store.hexSettings.wordRemappings)
|
||||
output = TranscriptFormattingApplier.apply(
|
||||
output,
|
||||
lowercase: store.hexSettings.lowercaseTranscripts,
|
||||
removePunctuation: store.hexSettings.removePunctuation
|
||||
)
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemovalRow: View {
|
||||
@Binding var removal: WordRemoval
|
||||
var onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("", isOn: $removal.isEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
.accessibilityLabel("Enable \(removal.pattern.isEmpty ? "removal" : removal.pattern)")
|
||||
|
||||
TextField("Regex Pattern", text: $removal.pattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.frame(width: Layout.deleteColumnWidth)
|
||||
.help("Delete removal")
|
||||
}
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemappingRow: View {
|
||||
@Binding var remapping: WordRemapping
|
||||
var onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("", isOn: $remapping.isEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
.accessibilityLabel("Enable \(remapping.match.isEmpty ? "replacement" : remapping.match)")
|
||||
|
||||
TextField("Match", text: $remapping.match)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: Layout.arrowColumnWidth)
|
||||
|
||||
TextField("Replace", text: $remapping.replacement)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.frame(width: Layout.deleteColumnWidth)
|
||||
.help("Delete replacement")
|
||||
}
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private enum ModificationSection: String, CaseIterable, Identifiable {
|
||||
case removals
|
||||
case remappings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .removals:
|
||||
return "Remove words"
|
||||
case .remappings:
|
||||
return "Replace words"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum Layout {
|
||||
static let toggleColumnWidth: CGFloat = 24
|
||||
static let deleteColumnWidth: CGFloat = 24
|
||||
static let arrowColumnWidth: CGFloat = 16
|
||||
static let rowHorizontalPadding: CGFloat = 10
|
||||
static let dividerLeadingPadding: CGFloat = toggleColumnWidth + rowHorizontalPadding + 8
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
struct AboutView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
@State var viewModel = CheckForUpdatesViewModel.shared
|
||||
@State private var showingChangelog = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
Label("Version", systemImage: "info.circle")
|
||||
Spacer()
|
||||
Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown")
|
||||
Button("Check for Updates") {
|
||||
viewModel.checkForUpdates()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!viewModel.canCheckForUpdates)
|
||||
}
|
||||
HStack {
|
||||
Label("Changelog", systemImage: "doc.text")
|
||||
Spacer()
|
||||
Button("Show Changelog") {
|
||||
showingChangelog.toggle()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.sheet(isPresented: $showingChangelog, onDismiss: {
|
||||
showingChangelog = false
|
||||
}) {
|
||||
ChangelogView()
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Label("Hex is open source", systemImage: "apple.terminal.on.rectangle")
|
||||
Spacer()
|
||||
Link("Visit our GitHub", destination: URL(string: "https://github.com/kitlangton/Hex/")!)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Label("Support the developer", systemImage: "heart")
|
||||
Spacer()
|
||||
Link("Become a Sponsor", destination: URL(string: "https://github.com/sponsors/kitlangton")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
import Inject
|
||||
import MarkdownUI
|
||||
|
||||
struct ChangelogView: View {
|
||||
@ObserveInjection var inject
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Changelog")
|
||||
.font(.title)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
if let changelogPath = Bundle.main.path(forResource: "changelog", ofType: "md"),
|
||||
let changelogContent = try? String(
|
||||
contentsOfFile: changelogPath, encoding: .utf8)
|
||||
{
|
||||
Markdown(changelogContent)
|
||||
} else {
|
||||
Text("Changelog could not be loaded.")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Label {
|
||||
Toggle("Open on Login",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.openOnLogin },
|
||||
set: { store.send(.toggleOpenOnLogin($0)) }
|
||||
))
|
||||
} icon: {
|
||||
Image(systemName: "arrow.right.circle")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Show Dock Icon",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.showDockIcon },
|
||||
set: { store.send(.toggleShowDockIcon($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "dock.rectangle")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Use clipboard to insert",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.useClipboardPaste },
|
||||
set: { store.send(.setUseClipboardPaste($0)) }
|
||||
)
|
||||
)
|
||||
Text("Use clipboard to insert text. Fast but may not restore all clipboard content.\nTurn off to use simulated keypresses. Slower, but doesn't need to restore clipboard")
|
||||
} icon: {
|
||||
Image(systemName: "doc.on.doc.fill")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Copy to clipboard",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.copyToClipboard },
|
||||
set: { store.send(.setCopyToClipboard($0)) }
|
||||
)
|
||||
)
|
||||
Text("Copy transcription text to clipboard in addition to pasting it")
|
||||
} icon: {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Prevent System Sleep while Recording",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.preventSystemSleep },
|
||||
set: { store.send(.togglePreventSystemSleep($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "zzz")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Super Fast Mode",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.superFastModeEnabled },
|
||||
set: { store.send(.toggleSuperFastMode($0)) }
|
||||
)
|
||||
)
|
||||
Text("Keep the microphone warm and prepend a short in-memory buffer for near-instant capture. macOS will keep showing the microphone indicator while this mode is armed.")
|
||||
} icon: {
|
||||
Image(systemName: "bolt.circle")
|
||||
}
|
||||
|
||||
Label {
|
||||
HStack(alignment: .center) {
|
||||
Text("Audio Behavior while Recording")
|
||||
Spacer()
|
||||
Picker("", selection: Binding(
|
||||
get: { store.hexSettings.recordingAudioBehavior },
|
||||
set: { store.send(.setRecordingAudioBehavior($0)) }
|
||||
)) {
|
||||
Label("Pause Media", systemImage: "pause")
|
||||
.tag(RecordingAudioBehavior.pauseMedia)
|
||||
Label("Mute Volume", systemImage: "speaker.slash")
|
||||
.tag(RecordingAudioBehavior.mute)
|
||||
Label("Do Nothing", systemImage: "hand.raised.slash")
|
||||
.tag(RecordingAudioBehavior.doNothing)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "speaker.wave.2")
|
||||
}
|
||||
} header: {
|
||||
Text("General")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
import HexCore
|
||||
|
||||
struct HistorySectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Label {
|
||||
Toggle("Save Transcription History", isOn: Binding(
|
||||
get: { store.hexSettings.saveTranscriptionHistory },
|
||||
set: { store.send(.toggleSaveTranscriptionHistory($0)) }
|
||||
))
|
||||
Text("Save transcriptions and audio recordings for later access")
|
||||
.settingsCaption()
|
||||
} icon: {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
}
|
||||
|
||||
if store.hexSettings.saveTranscriptionHistory {
|
||||
Label {
|
||||
HStack {
|
||||
Text("Maximum History Entries")
|
||||
Spacer()
|
||||
Picker("", selection: Binding(
|
||||
get: { store.hexSettings.maxHistoryEntries ?? 0 },
|
||||
set: { newValue in
|
||||
store.send(.setMaxHistoryEntries(newValue == 0 ? nil : newValue))
|
||||
}
|
||||
)) {
|
||||
Text("Unlimited").tag(0)
|
||||
Text("50").tag(50)
|
||||
Text("100").tag(100)
|
||||
Text("200").tag(200)
|
||||
Text("500").tag(500)
|
||||
Text("1000").tag(1000)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 120)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "number.square")
|
||||
}
|
||||
|
||||
if store.hexSettings.maxHistoryEntries != nil {
|
||||
Text("Oldest entries will be automatically deleted when limit is reached")
|
||||
.settingsCaption()
|
||||
.padding(.leading, 28)
|
||||
}
|
||||
|
||||
PasteLastTranscriptHotkeyRow(store: store)
|
||||
}
|
||||
} header: {
|
||||
Text("History")
|
||||
} footer: {
|
||||
if !store.hexSettings.saveTranscriptionHistory {
|
||||
Text("When disabled, transcriptions will not be saved and audio files will be deleted immediately after transcription.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
private struct PasteLastTranscriptHotkeyRow: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
let pasteHotkey = store.hexSettings.pasteLastTranscriptHotkey
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Paste Last Transcript")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Assign a shortcut (modifier + key) to instantly paste your last transcription.")
|
||||
.settingsCaption()
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
}
|
||||
|
||||
let key = store.isSettingPasteLastTranscriptHotkey ? nil : pasteHotkey?.key
|
||||
let modifiers = store.isSettingPasteLastTranscriptHotkey ? store.currentPasteLastModifiers : (pasteHotkey?.modifiers ?? .init(modifiers: []))
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
HotKeyView(modifiers: modifiers, key: key, isActive: store.isSettingPasteLastTranscriptHotkey)
|
||||
|
||||
if !store.isSettingPasteLastTranscriptHotkey, pasteHotkey == nil {
|
||||
Text("Not set")
|
||||
.settingsCaption()
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
store.send(.startSettingPasteLastTranscriptHotkey)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if store.isSettingPasteLastTranscriptHotkey {
|
||||
Text("Use at least one modifier (⌘, ⌥, ⇧, ⌃) plus a key.")
|
||||
.settingsCaption()
|
||||
} else if pasteHotkey != nil {
|
||||
Button {
|
||||
store.send(.clearPasteLastTranscriptHotkey)
|
||||
} label: {
|
||||
Label("Clear shortcut", systemImage: "xmark.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct HotKeySectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Section("Hot Key") {
|
||||
let hotKey = store.hexSettings.hotkey
|
||||
let key = store.isSettingHotKey ? nil : hotKey.key
|
||||
let modifiers = store.isSettingHotKey ? store.currentModifiers : hotKey.modifiers
|
||||
|
||||
VStack(spacing: 12) {
|
||||
// Hot key view
|
||||
HStack {
|
||||
Spacer()
|
||||
HotKeyView(modifiers: modifiers, key: key, isActive: store.isSettingHotKey)
|
||||
.animation(.spring(), value: key)
|
||||
.animation(.spring(), value: modifiers)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
store.send(.startSettingHotKey)
|
||||
}
|
||||
|
||||
if !store.isSettingHotKey,
|
||||
hotKey.key == nil,
|
||||
!hotKey.modifiers.isEmpty {
|
||||
ModifierSideControls(
|
||||
modifiers: hotKey.modifiers,
|
||||
onSelect: { kind, side in
|
||||
store.send(.setModifierSide(kind, side))
|
||||
}
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Enable double-tap lock",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.doubleTapLockEnabled },
|
||||
set: { store.send(.setDoubleTapLockEnabled($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "hand.tap")
|
||||
}
|
||||
|
||||
// Double-tap only mode applies to key+modifier combinations.
|
||||
if hotKey.key != nil {
|
||||
Label {
|
||||
Toggle(
|
||||
"Use double-tap only",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.useDoubleTapOnly },
|
||||
set: { store.send(.setUseDoubleTapOnly($0)) }
|
||||
)
|
||||
)
|
||||
.disabled(!store.hexSettings.doubleTapLockEnabled)
|
||||
} icon: {
|
||||
Image(systemName: "hand.tap.fill")
|
||||
}
|
||||
}
|
||||
|
||||
// Minimum key time (for modifier-only shortcuts)
|
||||
if store.hexSettings.hotkey.key == nil {
|
||||
Label {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { store.hexSettings.minimumKeyTime },
|
||||
set: { store.send(.setMinimumKeyTime($0)) }
|
||||
),
|
||||
in: 0.0 ... 2.0,
|
||||
step: 0.1
|
||||
) {
|
||||
Text("Ignore below \(store.hexSettings.minimumKeyTime, specifier: "%.1f")s")
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "clock")
|
||||
}
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModifierSideControls: View {
|
||||
@ObserveInjection var inject
|
||||
var modifiers: Modifiers
|
||||
var onSelect: (Modifier.Kind, Modifier.Side) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(modifiers.kinds, id: \.self) { kind in
|
||||
if kind.supportsSideSelection {
|
||||
let binding = Binding<Modifier.Side>(
|
||||
get: { modifiers.side(for: kind) ?? .either },
|
||||
set: { onSelect(kind, $0) }
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("\(kind.symbol) \(kind.displayName)")
|
||||
.settingsCaption()
|
||||
|
||||
Picker("Modifier side", selection: binding) {
|
||||
ForEach(Modifier.Side.allCases, id: \.self) { side in
|
||||
Text(side.displayName)
|
||||
.tag(side)
|
||||
.disabled(!kind.supportsSideSelection && side != .either)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// HotKeyView.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/30/25.
|
||||
//
|
||||
|
||||
import HexCore
|
||||
import Inject
|
||||
import Sauce
|
||||
import SwiftUI
|
||||
|
||||
// This view shows the actual "keys" in a more modern, subtle style.
|
||||
struct HotKeyView: View {
|
||||
@ObserveInjection var inject
|
||||
var modifiers: Modifiers
|
||||
var key: Key?
|
||||
var isActive: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
if modifiers.isHyperkey {
|
||||
// Show Black Four Pointed Star for hyperkey
|
||||
KeyView(text: "✦")
|
||||
.transition(.blurReplace)
|
||||
} else {
|
||||
ForEach(modifiers.sorted) { modifier in
|
||||
KeyView(text: modifier.stringValue)
|
||||
.transition(.blurReplace)
|
||||
}
|
||||
}
|
||||
|
||||
if let key {
|
||||
KeyView(text: key.toString)
|
||||
}
|
||||
|
||||
if modifiers.isEmpty && key == nil {
|
||||
Text("")
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.frame(width: 48, height: 48)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if isActive && key == nil && modifiers.isEmpty {
|
||||
Text("Enter a key combination")
|
||||
.foregroundColor(.secondary)
|
||||
.transition(.blurReplace)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.blue.opacity(isActive ? 0.1 : 0))
|
||||
.stroke(Color.blue.opacity(isActive ? 0.2 : 0), lineWidth: 1)
|
||||
)
|
||||
|
||||
.animation(.bouncy(duration: 0.3), value: key)
|
||||
.animation(.bouncy(duration: 0.3), value: modifiers)
|
||||
.animation(.bouncy(duration: 0.3), value: isActive)
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyView: View {
|
||||
@ObserveInjection var inject
|
||||
var text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.title.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(
|
||||
Color(white: 0.2)
|
||||
.shadow(.inner(color: .white.opacity(0.3), radius: 1, y: 1))
|
||||
.shadow(.inner(color: .white.opacity(0.1), radius: 5, y: 8))
|
||||
.shadow(.inner(color: .black.opacity(0.3), radius: 1, y: -3))
|
||||
)
|
||||
)
|
||||
.shadow(radius: 4, y: 2)
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HotKeyView(
|
||||
modifiers: .init(modifiers: [.command, .shift]),
|
||||
key: .a,
|
||||
isActive: true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
import Inject
|
||||
#if canImport(ComposableArchitecture)
|
||||
import ComposableArchitecture
|
||||
#endif
|
||||
|
||||
struct LanguageSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Picker(
|
||||
"Output Language",
|
||||
selection: Binding(
|
||||
get: { store.hexSettings.outputLanguage },
|
||||
set: { store.send(.setOutputLanguage($0)) }
|
||||
)
|
||||
) {
|
||||
ForEach(store.languages, id: \.id) { language in
|
||||
Text(language.name).tag(language.code as String?)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
} icon: {
|
||||
Image(systemName: "globe")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct MicrophoneSelectionSectionView: View {
|
||||
private static let systemDefaultTag = "__system_default__"
|
||||
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
private var selectedDeviceTag: Binding<String> {
|
||||
Binding(
|
||||
get: { store.hexSettings.selectedMicrophoneID ?? Self.systemDefaultTag },
|
||||
set: { newValue in
|
||||
store.send(.setSelectedMicrophoneID(newValue == Self.systemDefaultTag ? nil : newValue))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var missingSelectedDeviceID: String? {
|
||||
guard let selectedID = store.hexSettings.selectedMicrophoneID,
|
||||
!store.availableInputDevices.contains(where: { $0.id == selectedID })
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return selectedID
|
||||
}
|
||||
|
||||
private var pickerIdentity: String {
|
||||
[
|
||||
store.defaultInputDeviceName ?? "",
|
||||
store.hexSettings.selectedMicrophoneID ?? Self.systemDefaultTag,
|
||||
store.availableInputDevices.map { "\($0.id):\($0.name)" }.joined(separator: "|")
|
||||
].joined(separator: "||")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
// Input device picker
|
||||
HStack {
|
||||
Label {
|
||||
let systemLabel: String = {
|
||||
if let name = store.defaultInputDeviceName, !name.isEmpty {
|
||||
return "System Default (\(name))"
|
||||
}
|
||||
return "System Default"
|
||||
}()
|
||||
Picker("Input Device", selection: selectedDeviceTag) {
|
||||
Text(systemLabel).tag(Self.systemDefaultTag)
|
||||
ForEach(store.availableInputDevices) { device in
|
||||
Text(device.name).tag(device.id)
|
||||
}
|
||||
if let missingSelectedDeviceID {
|
||||
Text("Unavailable Device").tag(missingSelectedDeviceID)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.id(pickerIdentity)
|
||||
} icon: {
|
||||
Image(systemName: "mic.circle")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
store.send(.loadAvailableInputDevices)
|
||||
}) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Refresh available input devices")
|
||||
}
|
||||
|
||||
// Show fallback note for selected device not connected
|
||||
if missingSelectedDeviceID != nil {
|
||||
Text("Selected device not connected. System default will be used.")
|
||||
.settingsCaption()
|
||||
}
|
||||
} header: {
|
||||
Text("Microphone Selection")
|
||||
} footer: {
|
||||
Text("Override the system default microphone with a specific input device. This setting will persist across sessions.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+525
@@ -0,0 +1,525 @@
|
||||
// MARK: – ModelDownloadFeature.swift
|
||||
|
||||
// A full‐featured TCA reducer for managing on‑device ML models.
|
||||
// Dependencies: ComposableArchitecture, IdentifiedCollections, Dependencies
|
||||
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import IdentifiedCollections
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// MARK: – Data Models
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public struct ModelInfo: Equatable, Identifiable {
|
||||
public let name: String
|
||||
public var isDownloaded: Bool
|
||||
|
||||
public var id: String { name }
|
||||
public init(name: String, isDownloaded: Bool) {
|
||||
self.name = name
|
||||
self.isDownloaded = isDownloaded
|
||||
}
|
||||
}
|
||||
|
||||
public struct CuratedModelInfo: Equatable, Identifiable, Codable {
|
||||
public let displayName: String
|
||||
public let internalName: String
|
||||
public let size: String
|
||||
public let accuracyStars: Int
|
||||
public let speedStars: Int
|
||||
public let storageSize: String
|
||||
public var isDownloaded: Bool
|
||||
public var id: String { internalName }
|
||||
|
||||
public var badge: String? {
|
||||
switch parakeetModel {
|
||||
case .englishV2:
|
||||
return "BEST FOR ENGLISH"
|
||||
case .multilingualV3:
|
||||
return "BEST FOR MULTILINGUAL"
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var parakeetModel: ParakeetModel? {
|
||||
ParakeetModel(rawValue: internalName)
|
||||
}
|
||||
|
||||
var isParakeet: Bool {
|
||||
parakeetModel != nil
|
||||
}
|
||||
|
||||
public init(
|
||||
displayName: String,
|
||||
internalName: String,
|
||||
size: String,
|
||||
accuracyStars: Int,
|
||||
speedStars: Int,
|
||||
storageSize: String,
|
||||
isDownloaded: Bool
|
||||
) {
|
||||
self.displayName = displayName
|
||||
self.internalName = internalName
|
||||
self.size = size
|
||||
self.accuracyStars = accuracyStars
|
||||
self.speedStars = speedStars
|
||||
self.storageSize = storageSize
|
||||
self.isDownloaded = isDownloaded
|
||||
}
|
||||
|
||||
// Codable (isDownloaded is set at runtime)
|
||||
private enum CodingKeys: String, CodingKey { case displayName, internalName, size, accuracyStars, speedStars, storageSize }
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
displayName = try c.decode(String.self, forKey: .displayName)
|
||||
internalName = try c.decode(String.self, forKey: .internalName)
|
||||
size = try c.decode(String.self, forKey: .size)
|
||||
accuracyStars = try c.decode(Int.self, forKey: .accuracyStars)
|
||||
speedStars = try c.decode(Int.self, forKey: .speedStars)
|
||||
storageSize = try c.decode(String.self, forKey: .storageSize)
|
||||
isDownloaded = false
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience helper for loading the bundled models.json once.
|
||||
private enum CuratedModelLoader {
|
||||
private static let bundledModels: [CuratedModelInfo] = {
|
||||
guard let url = Bundle.main.url(forResource: "models", withExtension: "json") ??
|
||||
Bundle.main.url(forResource: "models", withExtension: "json", subdirectory: "Data")
|
||||
else {
|
||||
assertionFailure("models.json not found in bundle")
|
||||
return []
|
||||
}
|
||||
do { return try JSONDecoder().decode([CuratedModelInfo].self, from: Data(contentsOf: url)) }
|
||||
catch { assertionFailure("Failed to decode models.json - \(error)"); return [] }
|
||||
}()
|
||||
|
||||
static func load() -> [CuratedModelInfo] {
|
||||
bundledModels
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// MARK: – Domain
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Reducer
|
||||
public struct ModelDownloadFeature {
|
||||
@ObservableState
|
||||
public struct State: Equatable {
|
||||
// Shared user settings
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
|
||||
// Remote data
|
||||
public var availableModels: IdentifiedArrayOf<ModelInfo> = []
|
||||
public var curatedModels = IdentifiedArrayOf(uniqueElements: CuratedModelLoader.load())
|
||||
public var recommendedModel: String = ""
|
||||
|
||||
// UI state
|
||||
public var isLoadingModels = false
|
||||
public var isDownloading = false
|
||||
public var downloadProgress: Double = 0
|
||||
public var downloadError: String?
|
||||
public var downloadingModelName: String?
|
||||
|
||||
// Track which model generated a progress update to handle switching models
|
||||
public var activeDownloadID: UUID?
|
||||
|
||||
// Convenience computed vars
|
||||
var selectedModel: String { hexSettings.selectedModel }
|
||||
|
||||
/// The downloaded model matching the current selection, pattern-aware so
|
||||
/// legacy or glob-style selections (e.g. "distil*large-v3") still resolve.
|
||||
private var downloadedModelMatchingSelection: ModelInfo? {
|
||||
guard !selectedModel.isEmpty else { return nil }
|
||||
return availableModels.first { model in
|
||||
model.isDownloaded && ModelPatternMatcher.namesMatch(model.name, selectedModel)
|
||||
}
|
||||
}
|
||||
|
||||
var selectedModelNameForDisplay: String? {
|
||||
guard !selectedModel.isEmpty else { return nil }
|
||||
if let downloaded = downloadedModelMatchingSelection {
|
||||
return downloaded.name
|
||||
}
|
||||
if modelBootstrapState.isModelReady,
|
||||
let identifier = modelBootstrapState.modelIdentifier,
|
||||
ModelPatternMatcher.namesMatch(identifier, selectedModel)
|
||||
{
|
||||
return selectedModel
|
||||
}
|
||||
return hexSettings.hasCompletedModelBootstrap ? selectedModel : nil
|
||||
}
|
||||
|
||||
var selectedModelIsDownloaded: Bool {
|
||||
downloadedModelMatchingSelection != nil
|
||||
}
|
||||
|
||||
var anyModelDownloaded: Bool {
|
||||
availableModels.contains(where: { $0.isDownloaded })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
public enum Action: BindableAction {
|
||||
case binding(BindingAction<State>)
|
||||
// Requests
|
||||
case fetchModels
|
||||
case selectModel(String)
|
||||
case downloadModel(String)
|
||||
// Effects
|
||||
case modelsLoaded(recommended: String, available: [ModelInfo])
|
||||
case modelsLoadFailed
|
||||
case downloadProgress(id: UUID, progress: Double)
|
||||
case downloadCompleted(id: UUID, result: Result<String, Error>)
|
||||
case cancelDownload
|
||||
|
||||
case deleteModel(String)
|
||||
case modelDeleted(String)
|
||||
case modelDeletionFailed(Error)
|
||||
case openModelLocation(String)
|
||||
}
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
@Dependency(\.transcription) var transcription
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: Reducer
|
||||
|
||||
public var body: some ReducerOf<Self> {
|
||||
BindingReducer()
|
||||
Reduce(reduce)
|
||||
}
|
||||
|
||||
// MARK: - Helpers (pattern matching)
|
||||
|
||||
private func resolvePattern(_ pattern: String, from available: [ModelInfo]) -> String? {
|
||||
ModelPatternMatcher.resolvePattern(pattern, from: available.map { ($0.name, $0.isDownloaded) })
|
||||
}
|
||||
|
||||
private func curatedDisplayName(for model: String, curated: IdentifiedArrayOf<CuratedModelInfo>) -> String {
|
||||
if let match = curated.first(where: { ModelPatternMatcher.matches($0.internalName, model) }) {
|
||||
return match.displayName
|
||||
}
|
||||
return model
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.capitalized
|
||||
}
|
||||
|
||||
private func updateBootstrapState(_ state: inout State) {
|
||||
let model = state.hexSettings.selectedModel
|
||||
guard !model.isEmpty else {
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
bootstrap.modelIdentifier = ""
|
||||
bootstrap.modelDisplayName = ""
|
||||
bootstrap.isModelReady = false
|
||||
bootstrap.progress = 0
|
||||
bootstrap.lastError = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
let displayName = curatedDisplayName(for: model, curated: state.curatedModels)
|
||||
let isDownloaded = state.selectedModelIsDownloaded
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
bootstrap.modelIdentifier = model
|
||||
bootstrap.modelDisplayName = displayName
|
||||
bootstrap.isModelReady = isDownloaded
|
||||
if isDownloaded {
|
||||
bootstrap.lastError = nil
|
||||
bootstrap.progress = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reduce(state: inout State, action: Action) -> Effect<Action> {
|
||||
switch action {
|
||||
// MARK: – UI bindings
|
||||
|
||||
case .binding:
|
||||
return .none
|
||||
|
||||
case let .selectModel(model):
|
||||
// If the curated item is a glob (e.g., "distil*large-v3"),
|
||||
// resolve it to a concrete available model so both tabs stay in sync
|
||||
let resolved = resolvePattern(model, from: Array(state.availableModels)) ?? model
|
||||
state.$hexSettings.withLock { $0.selectedModel = resolved }
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
// MARK: – Fetch Models
|
||||
|
||||
case .fetchModels:
|
||||
guard !state.isLoadingModels else { return .none }
|
||||
state.isLoadingModels = true
|
||||
return .run { send in
|
||||
do {
|
||||
async let recommendedSupportTask = transcription.getRecommendedModels()
|
||||
async let availableNamesTask = transcription.getAvailableModels()
|
||||
let recommendedSupport = try await recommendedSupportTask
|
||||
let names = try await availableNamesTask
|
||||
let recommended = recommendedSupport.default
|
||||
let infos = try await withThrowingTaskGroup(of: ModelInfo.self) { group -> [ModelInfo] in
|
||||
for name in names {
|
||||
group.addTask {
|
||||
ModelInfo(
|
||||
name: name,
|
||||
isDownloaded: await transcription.isModelDownloaded(name)
|
||||
)
|
||||
}
|
||||
}
|
||||
return try await group.reduce(into: []) { $0.append($1) }
|
||||
}
|
||||
await send(.modelsLoaded(recommended: recommended, available: infos))
|
||||
} catch {
|
||||
await send(.modelsLoadFailed)
|
||||
}
|
||||
}
|
||||
|
||||
case .modelsLoadFailed:
|
||||
state.isLoadingModels = false
|
||||
return .none
|
||||
|
||||
case let .modelsLoaded(recommended, available):
|
||||
state.isLoadingModels = false
|
||||
// Ensure our curated Parakeet options are visible even if WhisperKit doesn't list them
|
||||
var availablePlus = available
|
||||
for model in ParakeetModel.allCases.reversed() {
|
||||
if !availablePlus.contains(where: { $0.name == model.identifier }) {
|
||||
availablePlus.insert(ModelInfo(name: model.identifier, isDownloaded: false), at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
if availablePlus.contains(where: { $0.name == state.preferredParakeetIdentifier }) {
|
||||
state.recommendedModel = state.preferredParakeetIdentifier
|
||||
} else {
|
||||
state.recommendedModel = recommended
|
||||
}
|
||||
state.availableModels = IdentifiedArrayOf(uniqueElements: availablePlus)
|
||||
|
||||
// If the selected model is a pattern, resolve it now to the first available match
|
||||
if state.hexSettings.selectedModel.contains("*") || state.hexSettings.selectedModel.contains("?") {
|
||||
if let resolved = resolvePattern(state.hexSettings.selectedModel, from: available) {
|
||||
state.$hexSettings.withLock { $0.selectedModel = resolved }
|
||||
}
|
||||
}
|
||||
|
||||
// Merge curated + download status with pattern support
|
||||
var curated = CuratedModelLoader.load()
|
||||
for idx in curated.indices {
|
||||
let internalName = curated[idx].internalName
|
||||
if let match = available.first(where: { ModelPatternMatcher.matches(internalName, $0.name) }) {
|
||||
curated[idx].isDownloaded = match.isDownloaded
|
||||
} else {
|
||||
curated[idx].isDownloaded = false
|
||||
}
|
||||
}
|
||||
state.curatedModels = IdentifiedArrayOf(uniqueElements: curated)
|
||||
// If the selection isn't installed but another model is, switch to the
|
||||
// installed one so transcription keeps working. Never clear the user's
|
||||
// selection outright: availability scans can produce false negatives
|
||||
// (e.g. after a dependency changes its cache layout), and wiping the
|
||||
// setting turns a transient glitch into a permanent silent failure.
|
||||
if !state.selectedModelIsDownloaded,
|
||||
let installedModel = state.curatedModels.first(where: \.isDownloaded)
|
||||
{
|
||||
let fallback = resolvePattern(installedModel.internalName, from: Array(state.availableModels)) ?? installedModel.internalName
|
||||
if fallback != state.selectedModel {
|
||||
state.$hexSettings.withLock { $0.selectedModel = fallback }
|
||||
}
|
||||
}
|
||||
updateBootstrapState(&state)
|
||||
if !state.anyModelDownloaded && !state.hexSettings.hasCompletedModelBootstrap {
|
||||
let preferred = state.recommendedModel.isEmpty ? state.hexSettings.selectedModel : state.recommendedModel
|
||||
if !preferred.isEmpty {
|
||||
state.$hexSettings.withLock { $0.selectedModel = preferred }
|
||||
updateBootstrapState(&state)
|
||||
}
|
||||
}
|
||||
return .none
|
||||
|
||||
// MARK: – Download
|
||||
|
||||
case let .downloadModel(requestedModel):
|
||||
guard !requestedModel.isEmpty, !state.isDownloading else { return .none }
|
||||
// Resolve glob/legacy selections to a concrete model name up front so
|
||||
// the completion handler updates the matching rows and writes a
|
||||
// concrete name back into settings.
|
||||
let model = resolvePattern(requestedModel, from: Array(state.availableModels)) ?? requestedModel
|
||||
state.downloadError = nil
|
||||
state.isDownloading = true
|
||||
state.downloadProgress = 0
|
||||
state.downloadingModelName = model
|
||||
state.activeDownloadID = UUID()
|
||||
let downloadID = state.activeDownloadID!
|
||||
if !state.anyModelDownloaded {
|
||||
let displayName = curatedDisplayName(for: model, curated: state.curatedModels)
|
||||
state.$modelBootstrapState.withLock {
|
||||
$0.modelIdentifier = model
|
||||
$0.modelDisplayName = displayName
|
||||
$0.isModelReady = false
|
||||
$0.progress = 0
|
||||
$0.lastError = nil
|
||||
}
|
||||
}
|
||||
return .run { send in
|
||||
do {
|
||||
try await transcription.downloadModel(model) { progress in
|
||||
let fractionCompleted = progress.fractionCompleted
|
||||
Task {
|
||||
await send(.downloadProgress(id: downloadID, progress: fractionCompleted))
|
||||
}
|
||||
}
|
||||
await send(.downloadCompleted(id: downloadID, result: .success(model)))
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
await send(.downloadCompleted(id: downloadID, result: .failure(error)))
|
||||
}
|
||||
}
|
||||
.cancellable(id: downloadID)
|
||||
|
||||
case let .downloadProgress(id, progress):
|
||||
guard state.activeDownloadID == id else { return .none }
|
||||
guard state.downloadProgress != progress else { return .none }
|
||||
state.downloadProgress = progress
|
||||
if !state.modelBootstrapState.isModelReady {
|
||||
state.$modelBootstrapState.withLock { $0.progress = progress }
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .downloadCompleted(id, result):
|
||||
guard state.activeDownloadID == id else { return .none }
|
||||
state.isDownloading = false
|
||||
state.downloadingModelName = nil
|
||||
state.activeDownloadID = nil
|
||||
state.downloadProgress = 0
|
||||
var failureMessage: String?
|
||||
switch result {
|
||||
case let .success(name):
|
||||
state.availableModels[id: name]?.isDownloaded = true
|
||||
if let idx = state.curatedModels.firstIndex(where: { $0.internalName == name }) {
|
||||
state.curatedModels[idx].isDownloaded = true
|
||||
}
|
||||
state.$hexSettings.withLock { settings in
|
||||
settings.selectedModel = name
|
||||
settings.hasCompletedModelBootstrap = true
|
||||
}
|
||||
state.downloadError = nil
|
||||
case let .failure(err):
|
||||
let ns = err as NSError
|
||||
var message = ns.localizedDescription
|
||||
if let url = ns.userInfo[NSURLErrorFailingURLErrorKey] as? URL,
|
||||
let host = url.host
|
||||
{
|
||||
message += " (\(host))"
|
||||
} else if let str = ns.userInfo[NSURLErrorFailingURLStringErrorKey] as? String,
|
||||
let u = URL(string: str), let host = u.host
|
||||
{
|
||||
message += " (\(host))"
|
||||
}
|
||||
state.downloadError = message
|
||||
failureMessage = message
|
||||
}
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
if let failureMessage {
|
||||
bootstrap.isModelReady = false
|
||||
bootstrap.lastError = failureMessage
|
||||
bootstrap.progress = 0
|
||||
} else {
|
||||
bootstrap.isModelReady = true
|
||||
bootstrap.lastError = nil
|
||||
bootstrap.progress = 1
|
||||
}
|
||||
}
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
case .cancelDownload:
|
||||
guard let id = state.activeDownloadID else { return .none }
|
||||
state.isDownloading = false
|
||||
state.downloadingModelName = nil
|
||||
state.activeDownloadID = nil
|
||||
state.downloadProgress = 0
|
||||
state.$modelBootstrapState.withLock { $0.progress = 0 }
|
||||
updateBootstrapState(&state)
|
||||
return .cancel(id: id)
|
||||
|
||||
case let .deleteModel(model):
|
||||
guard !model.isEmpty else { return .none }
|
||||
let resolved = resolvePattern(model, from: Array(state.availableModels)) ?? model
|
||||
if ModelPatternMatcher.namesMatch(model, state.selectedModel) {
|
||||
state.$modelBootstrapState.withLock { $0.isModelReady = false }
|
||||
}
|
||||
return .run { send in
|
||||
do {
|
||||
try await transcription.deleteModel(resolved)
|
||||
await send(.modelDeleted(resolved))
|
||||
} catch {
|
||||
await send(.modelDeletionFailed(error))
|
||||
}
|
||||
}
|
||||
|
||||
case let .modelDeleted(model):
|
||||
state.availableModels[id: model]?.isDownloaded = false
|
||||
for index in state.curatedModels.indices
|
||||
where ModelPatternMatcher.matches(state.curatedModels[index].internalName, model) {
|
||||
state.curatedModels[index].isDownloaded = false
|
||||
}
|
||||
if ModelPatternMatcher.namesMatch(state.selectedModel, model) {
|
||||
let fallback = state.availableModels.first { $0.isDownloaded }?.name ?? ""
|
||||
state.$hexSettings.withLock { $0.selectedModel = fallback }
|
||||
updateBootstrapState(&state)
|
||||
}
|
||||
return .send(.fetchModels)
|
||||
|
||||
case let .modelDeletionFailed(error):
|
||||
state.downloadError = error.localizedDescription
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
case let .openModelLocation(model):
|
||||
return openModelLocationEffect(for: model)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private func openModelLocationEffect(for model: String) -> Effect<Action> {
|
||||
// Parakeet caches live under FluidAudio's directory, not the WhisperKit
|
||||
// models folder. Route "Show in Finder" to the matching root so users
|
||||
// don't end up staring at an empty WhisperKit folder thinking the
|
||||
// Parakeet download silently failed.
|
||||
let usesParakeetRoot = ParakeetModel(rawValue: model) != nil
|
||||
return .run { _ in
|
||||
let base = try usesParakeetRoot
|
||||
? URL.hexParakeetModelsDirectory
|
||||
: URL.hexModelsDirectory
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: base.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ModelDownloadFeature.State {
|
||||
var preferredParakeetIdentifier: String {
|
||||
(prefersEnglishParakeet ? ParakeetModel.englishV2 : ParakeetModel.multilingualV3).identifier
|
||||
}
|
||||
|
||||
private var prefersEnglishParakeet: Bool {
|
||||
guard let language = hexSettings.outputLanguage?.lowercased(), !language.isEmpty else {
|
||||
return true
|
||||
}
|
||||
return language.hasPrefix("en")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
private func modelNamesMatch(_ lhs: String, _ rhs: String) -> Bool {
|
||||
ModelPatternMatcher.namesMatch(lhs, rhs)
|
||||
}
|
||||
|
||||
public struct ModelDownloadView: View {
|
||||
@ObserveInjection var inject
|
||||
|
||||
@Bindable var store: StoreOf<ModelDownloadFeature>
|
||||
@State private var isModelLibraryPresented = false
|
||||
var shouldFlash: Bool = false
|
||||
|
||||
public init(store: StoreOf<ModelDownloadFeature>, shouldFlash: Bool = false) {
|
||||
self.store = store
|
||||
self.shouldFlash = shouldFlash
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if !store.modelBootstrapState.isModelReady,
|
||||
let message = store.modelBootstrapState.lastError,
|
||||
!message.isEmpty
|
||||
{
|
||||
AutoDownloadBannerView(
|
||||
title: "Download failed",
|
||||
subtitle: message,
|
||||
progress: nil,
|
||||
style: .error
|
||||
)
|
||||
}
|
||||
if selectedModelName == nil, downloadingModel == nil {
|
||||
NoModelChooser(
|
||||
models: parakeetModels,
|
||||
suggestedModel: suggestedParakeetIdentifier,
|
||||
isLoading: store.isLoadingModels,
|
||||
isFlashing: shouldFlash,
|
||||
onDownload: { store.send(.downloadModel($0.internalName)) },
|
||||
onBrowse: { isModelLibraryPresented = true },
|
||||
onRetry: { store.send(.fetchModels) }
|
||||
)
|
||||
} else {
|
||||
CurrentModelSummary(
|
||||
model: selectedModel,
|
||||
selectedModelName: selectedModelName,
|
||||
isInstalled: store.selectedModelIsDownloaded,
|
||||
isDownloadingAnything: store.isDownloading,
|
||||
downloadingModel: downloadingModel,
|
||||
downloadProgress: store.downloadProgress,
|
||||
onBrowse: { isModelLibraryPresented = true },
|
||||
onDownload: {
|
||||
if let name = selectedModelName {
|
||||
store.send(.downloadModel(name))
|
||||
}
|
||||
},
|
||||
onCancelDownload: { store.send(.cancelDownload) }
|
||||
)
|
||||
}
|
||||
if let err = store.downloadError,
|
||||
err != store.modelBootstrapState.lastError
|
||||
{
|
||||
Text("Model Error: \(err)")
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.sheet(isPresented: $isModelLibraryPresented) {
|
||||
ModelLibrarySheet(store: store)
|
||||
}
|
||||
.task {
|
||||
if store.availableModels.isEmpty {
|
||||
store.send(.fetchModels)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
private var selectedModel: CuratedModelInfo? {
|
||||
guard let selectedModelName else { return nil }
|
||||
return store.curatedModels.first { model in
|
||||
modelNamesMatch(model.internalName, selectedModelName)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedModelName: String? {
|
||||
store.selectedModelNameForDisplay
|
||||
}
|
||||
|
||||
private var downloadingModel: CuratedModelInfo? {
|
||||
guard let downloadingModelName = store.downloadingModelName else { return nil }
|
||||
return store.curatedModels.first { model in
|
||||
modelNamesMatch(model.internalName, downloadingModelName)
|
||||
}
|
||||
}
|
||||
|
||||
private var parakeetModels: [CuratedModelInfo] {
|
||||
let models = store.curatedModels.filter(\.isParakeet)
|
||||
return models.filter { modelNamesMatch($0.internalName, suggestedParakeetIdentifier) } +
|
||||
models.filter { !modelNamesMatch($0.internalName, suggestedParakeetIdentifier) }
|
||||
}
|
||||
|
||||
private var suggestedParakeetIdentifier: String {
|
||||
store.preferredParakeetIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoModelChooser: View {
|
||||
let models: [CuratedModelInfo]
|
||||
let suggestedModel: String
|
||||
let isLoading: Bool
|
||||
let isFlashing: Bool
|
||||
let onDownload: (CuratedModelInfo) -> Void
|
||||
let onBrowse: () -> Void
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Choose a transcription model")
|
||||
.font(.body.weight(.medium))
|
||||
Text("Download a local model to start transcribing.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("View All Models…", action: onBrowse)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
if models.isEmpty {
|
||||
if isLoading {
|
||||
Text("Loading model choices…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
HStack {
|
||||
Text("Model choices couldn’t be loaded.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry", action: onRetry)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(models) { model in
|
||||
NoModelCard(
|
||||
model: model,
|
||||
isRecommended: matches(model),
|
||||
isFlashing: isFlashing && matches(model),
|
||||
onDownload: { onDownload(model) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(borderColor, lineWidth: isFlashing ? 3 : 1)
|
||||
.animation(.easeInOut(duration: 0.5).repeatCount(3, autoreverses: true), value: isFlashing)
|
||||
)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if isFlashing { return Color.accentColor }
|
||||
return Color.primary.opacity(0.08)
|
||||
}
|
||||
|
||||
private func matches(_ model: CuratedModelInfo) -> Bool {
|
||||
modelNamesMatch(model.internalName, suggestedModel)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoModelCard: View {
|
||||
let model: CuratedModelInfo
|
||||
let isRecommended: Bool
|
||||
let isFlashing: Bool
|
||||
let onDownload: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onDownload) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(model.displayName)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer(minLength: 8)
|
||||
if isRecommended {
|
||||
Text("Suggested")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(model.size)
|
||||
Text("·")
|
||||
Text(model.storageSize)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(NSColor.windowBackgroundColor).opacity(0.45), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(borderColor, lineWidth: isFlashing ? 3 : 1)
|
||||
.animation(.easeInOut(duration: 0.5).repeatCount(3, autoreverses: true), value: isFlashing)
|
||||
)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if isFlashing { return Color.accentColor }
|
||||
if isRecommended { return Color.accentColor.opacity(0.45) }
|
||||
return Color.primary.opacity(0.08)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CurrentModelSummary: View {
|
||||
let model: CuratedModelInfo?
|
||||
let selectedModelName: String?
|
||||
let isInstalled: Bool
|
||||
let isDownloadingAnything: Bool
|
||||
let downloadingModel: CuratedModelInfo?
|
||||
let downloadProgress: Double
|
||||
let onBrowse: () -> Void
|
||||
let onDownload: () -> Void
|
||||
let onCancelDownload: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: iconName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(needsDownload ? Color.orange : Color.accentColor)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(.body.weight(.medium))
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(needsDownload ? Color.orange : Color.secondary)
|
||||
if let activeDownloadStatus {
|
||||
Text(activeDownloadStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if isDownloadingAnything {
|
||||
ProgressView(value: downloadProgress)
|
||||
.progressViewStyle(.linear)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isDownloadingAnything {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("\(Int(downloadProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
Button("Cancel", role: .destructive, action: onCancelDownload)
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
if needsDownload {
|
||||
Button("Download", action: onDownload)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
Button("Browse Models…", action: onBrowse)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// The selection references a model that isn't on disk (e.g. the scan came
|
||||
/// back empty after an update). Offer a direct download instead of
|
||||
/// pretending it's installed.
|
||||
private var needsDownload: Bool {
|
||||
selectedModelName != nil && !isInstalled && !isDownloadingAnything
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
if let model { return model.displayName }
|
||||
if let selectedModelName {
|
||||
return selectedModelName
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.capitalized
|
||||
}
|
||||
if let downloadingModel { return "Downloading \(downloadingModel.displayName)…" }
|
||||
return "Choose a transcription model"
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if needsDownload { return "Not downloaded — transcription won't work until you download it." }
|
||||
if let model { return "\(model.size) · \(model.storageSize)" }
|
||||
if selectedModelName != nil { return "Installed local model" }
|
||||
if let downloadingModel { return "\(downloadingModel.storageSize) will be stored locally on this Mac." }
|
||||
return "Download a local model to start transcribing."
|
||||
}
|
||||
|
||||
private var activeDownloadStatus: String? {
|
||||
guard selectedModelName != nil, let downloadingModel else { return nil }
|
||||
return "Downloading \(downloadingModel.displayName)…"
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
if needsDownload { return "exclamationmark.triangle.fill" }
|
||||
if selectedModelName == nil { return "arrow.down.circle.fill" }
|
||||
return "waveform"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct ModelLibrarySheet: View {
|
||||
@Bindable var store: StoreOf<ModelDownloadFeature>
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var pendingDelete: CuratedModelInfo?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Model Library")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Select an installed model to use it, or download another model for local transcription.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if let recommendedLibraryModel {
|
||||
modelSection(title: "Recommended", models: [recommendedLibraryModel], showsBadges: false)
|
||||
}
|
||||
if !otherLibraryModels.isEmpty {
|
||||
modelSection(title: "Other Models", models: otherLibraryModels, showsBadges: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label("Models run locally on your Mac. Downloads are stored on this device.", systemImage: "lock.shield")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(minWidth: 680, minHeight: 420)
|
||||
.confirmationDialog(
|
||||
"Remove \(pendingDelete?.displayName ?? "model")?",
|
||||
isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let pendingDelete {
|
||||
Button("Remove Download", role: .destructive) {
|
||||
store.send(.deleteModel(pendingDelete.internalName))
|
||||
self.pendingDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
} message: {
|
||||
if let pendingDelete {
|
||||
Text("This frees \(pendingDelete.storageSize) on this Mac. You can download it again anytime.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func select(_ model: CuratedModelInfo) {
|
||||
guard model.isDownloaded, !store.isDownloading else { return }
|
||||
store.send(.selectModel(model.internalName))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func modelSection(title: String, models: [CuratedModelInfo], showsBadges: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 2)
|
||||
VStack(spacing: 0) {
|
||||
ForEach(models) { model in
|
||||
ModelLibraryRow(
|
||||
model: model,
|
||||
isSelected: model.isDownloaded && isSelected(model),
|
||||
isDownloading: isDownloading(model),
|
||||
// Only the active row receives live progress so the other
|
||||
// rows don't re-render on every tick.
|
||||
downloadProgress: isDownloading(model) ? store.downloadProgress : 0,
|
||||
isDisabled: store.isDownloading && !isDownloading(model),
|
||||
showsBadge: showsBadges && !model.isDownloaded,
|
||||
onSelect: { select(model) },
|
||||
onDownload: { store.send(.downloadModel(model.internalName)) },
|
||||
onCancelDownload: { store.send(.cancelDownload) },
|
||||
onShowInFinder: { store.send(.openModelLocation(model.internalName)) },
|
||||
onDelete: { pendingDelete = model }
|
||||
)
|
||||
if model.id != models.last?.id {
|
||||
Divider().padding(.leading, 54)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
private var recommendedLibraryModel: CuratedModelInfo? {
|
||||
store.curatedModels.first { modelNamesMatch($0.internalName, suggestedParakeetIdentifier) } ?? store.curatedModels.first(where: \.isParakeet)
|
||||
}
|
||||
|
||||
private var otherLibraryModels: [CuratedModelInfo] {
|
||||
guard let recommendedLibraryModel else { return Array(store.curatedModels) }
|
||||
return store.curatedModels.filter { $0.id != recommendedLibraryModel.id }
|
||||
}
|
||||
|
||||
private func isSelected(_ model: CuratedModelInfo) -> Bool {
|
||||
let selected = store.hexSettings.selectedModel
|
||||
return modelNamesMatch(model.internalName, selected)
|
||||
}
|
||||
|
||||
private func isDownloading(_ model: CuratedModelInfo) -> Bool {
|
||||
store.isDownloading && store.downloadingModelName == model.internalName
|
||||
}
|
||||
|
||||
private var suggestedParakeetIdentifier: String {
|
||||
store.preferredParakeetIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModelLibraryRow: View {
|
||||
let model: CuratedModelInfo
|
||||
let isSelected: Bool
|
||||
let isDownloading: Bool
|
||||
let downloadProgress: Double
|
||||
let isDisabled: Bool
|
||||
let showsBadge: Bool
|
||||
let onSelect: () -> Void
|
||||
let onDownload: () -> Void
|
||||
let onCancelDownload: () -> Void
|
||||
let onShowInFinder: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Button(action: onSelect) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: leadingIcon)
|
||||
.foregroundStyle(leadingColor)
|
||||
.font(.body)
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 7) {
|
||||
Text(model.displayName)
|
||||
.font(.body.weight(.medium))
|
||||
if showsBadge, let badge = model.badge {
|
||||
Text(badge)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Text(model.size)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 5) {
|
||||
Text("Accuracy").font(.caption2).foregroundStyle(.secondary)
|
||||
StarRatingView(model.accuracyStars)
|
||||
}
|
||||
HStack(spacing: 5) {
|
||||
Text("Speed").font(.caption2).foregroundStyle(.secondary)
|
||||
StarRatingView(model.speedStars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
Text(model.storageSize)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let statusText {
|
||||
Text(statusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
trailingControls
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.disabled(isDisabled)
|
||||
.opacity(isDisabled ? 0.55 : 1)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.06) : Color.clear)
|
||||
.contextMenu {
|
||||
if isDownloading {
|
||||
Button("Cancel Download", role: .destructive, action: onCancelDownload)
|
||||
Button("Show in Finder", action: onShowInFinder)
|
||||
}
|
||||
if model.isDownloaded {
|
||||
managementMenuItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared by the trailing ⋯ menu and the right-click context menu.
|
||||
@ViewBuilder
|
||||
private var managementMenuItems: some View {
|
||||
Button("Show in Finder", action: onShowInFinder)
|
||||
Divider()
|
||||
Button("Remove Download…", role: .destructive, action: onDelete)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingControls: some View {
|
||||
if isDownloading {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView(value: downloadProgress)
|
||||
.progressViewStyle(.circular)
|
||||
.controlSize(.small)
|
||||
Text("\(Int(downloadProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
Button("Cancel", role: .destructive, action: onCancelDownload)
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else if model.isDownloaded {
|
||||
Menu {
|
||||
managementMenuItems
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
.help("Show in Finder or remove this download")
|
||||
} else {
|
||||
Button("Download", action: onDownload)
|
||||
.controlSize(.small)
|
||||
.help("Download \(model.storageSize) and switch to this model")
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String? {
|
||||
if isDownloading { return "Downloading…" }
|
||||
if isSelected { return "In Use" }
|
||||
if model.isDownloaded { return "Installed" }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var leadingIcon: String {
|
||||
if isDownloading { return "arrow.down.circle.fill" }
|
||||
if isSelected { return "checkmark.circle.fill" }
|
||||
if model.isDownloaded { return "circle" }
|
||||
return "arrow.down.circle"
|
||||
}
|
||||
|
||||
private var leadingColor: Color {
|
||||
if isDownloading || isSelected { return .accentColor }
|
||||
return .secondary
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct StarRatingView: View {
|
||||
@ObserveInjection var inject
|
||||
let filled: Int
|
||||
let max: Int
|
||||
|
||||
init(_ filled: Int, max: Int = 5) {
|
||||
self.filled = filled
|
||||
self.max = max
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(0 ..< max, id: \.self) { i in
|
||||
Image(systemName: i < filled ? "circle.fill" : "circle")
|
||||
.font(.system(size: 7))
|
||||
.foregroundColor(i < filled ? .blue : .gray.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct ModelSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
let shouldFlash: Bool
|
||||
|
||||
var body: some View {
|
||||
Section("Transcription Model") {
|
||||
ModelDownloadView(
|
||||
store: store.scope(state: \.modelDownload, action: \.modelDownload),
|
||||
shouldFlash: shouldFlash
|
||||
)
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct PermissionsSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
let microphonePermission: PermissionStatus
|
||||
let accessibilityPermission: PermissionStatus
|
||||
let inputMonitoringPermission: PermissionStatus
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
// Microphone
|
||||
permissionCard(
|
||||
title: "Microphone",
|
||||
icon: "mic.fill",
|
||||
status: microphonePermission,
|
||||
action: { store.send(.requestMicrophone) }
|
||||
)
|
||||
|
||||
// Accessibility + Keyboard
|
||||
permissionCard(
|
||||
title: "Accessibility",
|
||||
icon: "accessibility",
|
||||
status: combinedAccessibilityStatus,
|
||||
action: {
|
||||
store.send(.requestAccessibility)
|
||||
store.send(.requestInputMonitoring)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if store.hotkeyPermissionState.inputMonitoring != .granted {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label {
|
||||
Text("Input Monitoring is required so Hex can listen for your hotkey.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Button {
|
||||
store.send(.requestInputMonitoring)
|
||||
} label: {
|
||||
Text("Open Input Monitoring Settings")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
} header: {
|
||||
Text("Permissions")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func permissionCard(
|
||||
title: String,
|
||||
icon: String,
|
||||
status: PermissionStatus,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 16)
|
||||
|
||||
Text(title)
|
||||
.font(.body.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
switch status {
|
||||
case .granted:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.body)
|
||||
case .denied, .notDetermined:
|
||||
Button("Grant") {
|
||||
action()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var combinedAccessibilityStatus: PermissionStatus {
|
||||
if accessibilityPermission == .granted && inputMonitoringPermission == .granted {
|
||||
return .granted
|
||||
}
|
||||
if accessibilityPermission == .denied || inputMonitoringPermission == .denied {
|
||||
return .denied
|
||||
}
|
||||
return .notDetermined
|
||||
}
|
||||
}
|
||||
+580
@@ -0,0 +1,580 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import CoreAudio
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import IdentifiedCollections
|
||||
import Sauce
|
||||
import ServiceManagement
|
||||
import SwiftUI
|
||||
|
||||
private let settingsLogger = HexLog.settings
|
||||
private typealias SettingsAudioPropertyListenerBlock = @convention(block) (UInt32, UnsafePointer<AudioObjectPropertyAddress>) -> Void
|
||||
|
||||
private enum HotKeyCaptureTarget {
|
||||
case recording
|
||||
case pasteLastTranscript
|
||||
}
|
||||
|
||||
extension SharedReaderKey
|
||||
where Self == InMemoryKey<Bool>.Default
|
||||
{
|
||||
static var isSettingHotKey: Self {
|
||||
Self[.inMemory("isSettingHotKey"), default: false]
|
||||
}
|
||||
|
||||
static var isSettingPasteLastTranscriptHotkey: Self {
|
||||
Self[.inMemory("isSettingPasteLastTranscriptHotkey"), default: false]
|
||||
}
|
||||
|
||||
static var isRemappingScratchpadFocused: Self {
|
||||
Self[.inMemory("isRemappingScratchpadFocused"), default: false]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Feature
|
||||
|
||||
@Reducer
|
||||
struct SettingsFeature {
|
||||
@ObservableState
|
||||
struct State {
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.isSettingHotKey) var isSettingHotKey: Bool = false
|
||||
@Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool = false
|
||||
@Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
@Shared(.hotkeyPermissionState) var hotkeyPermissionState: HotkeyPermissionState
|
||||
|
||||
var languages: IdentifiedArrayOf<Language> = []
|
||||
var currentModifiers: Modifiers = .init(modifiers: [])
|
||||
var currentPasteLastModifiers: Modifiers = .init(modifiers: [])
|
||||
var remappingScratchpadText: String = ""
|
||||
|
||||
// Available microphones
|
||||
var availableInputDevices: [AudioInputDevice] = []
|
||||
var defaultInputDeviceName: String?
|
||||
|
||||
// Model Management
|
||||
var modelDownload = ModelDownloadFeature.State()
|
||||
var shouldFlashModelSection = false
|
||||
|
||||
}
|
||||
|
||||
enum Action: BindableAction {
|
||||
case binding(BindingAction<State>)
|
||||
|
||||
// Existing
|
||||
case task
|
||||
case startSettingHotKey
|
||||
case startSettingPasteLastTranscriptHotkey
|
||||
case clearPasteLastTranscriptHotkey
|
||||
case keyEvent(KeyEvent)
|
||||
case toggleOpenOnLogin(Bool)
|
||||
case toggleShowDockIcon(Bool)
|
||||
case togglePreventSystemSleep(Bool)
|
||||
case setRecordingAudioBehavior(RecordingAudioBehavior)
|
||||
case toggleSuperFastMode(Bool)
|
||||
case setUseClipboardPaste(Bool)
|
||||
case setCopyToClipboard(Bool)
|
||||
case setDoubleTapLockEnabled(Bool)
|
||||
case setUseDoubleTapOnly(Bool)
|
||||
case setMinimumKeyTime(Double)
|
||||
case setOutputLanguage(String?)
|
||||
case setSelectedMicrophoneID(String?)
|
||||
case setSoundEffectsEnabled(Bool)
|
||||
case setSoundEffectsVolume(Double)
|
||||
|
||||
// Permission delegation (forwarded to AppFeature)
|
||||
case requestMicrophone
|
||||
case requestAccessibility
|
||||
case requestInputMonitoring
|
||||
|
||||
// Microphone selection
|
||||
case loadAvailableInputDevices
|
||||
case availableInputDevicesLoaded([AudioInputDevice], String?)
|
||||
|
||||
// Model Management
|
||||
case modelDownload(ModelDownloadFeature.Action)
|
||||
|
||||
// History Management
|
||||
case toggleSaveTranscriptionHistory(Bool)
|
||||
case setMaxHistoryEntries(Int?)
|
||||
|
||||
// Modifier configuration
|
||||
case setModifierSide(Modifier.Kind, Modifier.Side)
|
||||
|
||||
// Word remappings
|
||||
case setWordRemovalsEnabled(Bool)
|
||||
case addWordRemoval
|
||||
case updateWordRemoval(WordRemoval)
|
||||
case removeWordRemoval(UUID)
|
||||
case addWordRemapping
|
||||
case updateWordRemapping(WordRemapping)
|
||||
case removeWordRemapping(UUID)
|
||||
case setRemappingScratchpadFocused(Bool)
|
||||
case setLowercaseTranscripts(Bool)
|
||||
case setRemovePunctuation(Bool)
|
||||
}
|
||||
|
||||
@Dependency(\.keyEventMonitor) var keyEventMonitor
|
||||
@Dependency(\.transcription) var transcription
|
||||
@Dependency(\.recording) var recording
|
||||
@Dependency(\.soundEffects) var soundEffects
|
||||
@Dependency(\.transcriptPersistence) var transcriptPersistence
|
||||
|
||||
private func deleteAudioEffect(for transcripts: [Transcript]) -> Effect<Action> {
|
||||
.run { [transcriptPersistence] _ in
|
||||
for transcript in transcripts {
|
||||
try? await transcriptPersistence.deleteAudio(transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginCapture(_ target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.$isSettingHotKey.withLock { $0 = true }
|
||||
state.currentModifiers = .init(modifiers: [])
|
||||
case .pasteLastTranscript:
|
||||
state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = true }
|
||||
state.currentPasteLastModifiers = .init(modifiers: [])
|
||||
}
|
||||
}
|
||||
|
||||
private func endCapture(_ target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.$isSettingHotKey.withLock { $0 = false }
|
||||
state.currentModifiers = .init(modifiers: [])
|
||||
case .pasteLastTranscript:
|
||||
state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = false }
|
||||
state.currentPasteLastModifiers = .init(modifiers: [])
|
||||
}
|
||||
}
|
||||
|
||||
private func captureModifiers(for target: HotKeyCaptureTarget, state: State) -> Modifiers {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.currentModifiers
|
||||
case .pasteLastTranscript:
|
||||
state.currentPasteLastModifiers
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCaptureModifiers(_ modifiers: Modifiers, for target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.currentModifiers = modifiers
|
||||
case .pasteLastTranscript:
|
||||
state.currentPasteLastModifiers = modifiers
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCapturedHotKey(key: Key?, modifiers: Modifiers, for target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.$hexSettings.withLock {
|
||||
$0.hotkey.key = key
|
||||
$0.hotkey.modifiers = modifiers.erasingSides()
|
||||
}
|
||||
case .pasteLastTranscript:
|
||||
guard let key else { return }
|
||||
state.$hexSettings.withLock {
|
||||
$0.pasteLastTranscriptHotkey = HotKey(key: key, modifiers: modifiers.erasingSides())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCapture(_ keyEvent: KeyEvent, for target: HotKeyCaptureTarget, state: inout State) -> Effect<Action> {
|
||||
if keyEvent.key == .escape {
|
||||
endCapture(target, state: &state)
|
||||
return .none
|
||||
}
|
||||
|
||||
let updatedModifiers = keyEvent.modifiers.union(captureModifiers(for: target, state: state))
|
||||
updateCaptureModifiers(updatedModifiers, for: target, state: &state)
|
||||
|
||||
if target == .pasteLastTranscript, keyEvent.key != nil, updatedModifiers.isEmpty {
|
||||
return .none
|
||||
}
|
||||
|
||||
if let key = keyEvent.key {
|
||||
applyCapturedHotKey(key: key, modifiers: updatedModifiers, for: target, state: &state)
|
||||
endCapture(target, state: &state)
|
||||
return .none
|
||||
}
|
||||
|
||||
if target == .recording, keyEvent.modifiers.isEmpty {
|
||||
applyCapturedHotKey(key: nil, modifiers: updatedModifiers, for: target, state: &state)
|
||||
endCapture(target, state: &state)
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
BindingReducer()
|
||||
|
||||
Scope(state: \.modelDownload, action: \.modelDownload) {
|
||||
ModelDownloadFeature()
|
||||
}
|
||||
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .binding:
|
||||
let didNormalizeDoubleTapOnly = !state.hexSettings.doubleTapLockEnabled && state.hexSettings.useDoubleTapOnly
|
||||
if didNormalizeDoubleTapOnly {
|
||||
state.$hexSettings.withLock {
|
||||
$0.useDoubleTapOnly = false
|
||||
}
|
||||
}
|
||||
|
||||
return .none
|
||||
|
||||
case .task:
|
||||
if let url = Bundle.main.url(forResource: "languages", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let languages = try? JSONDecoder().decode([Language].self, from: data)
|
||||
{
|
||||
state.languages = IdentifiedArray(uniqueElements: languages)
|
||||
} else {
|
||||
settingsLogger.error("Failed to load languages JSON from bundle")
|
||||
}
|
||||
|
||||
// Listen for key events and load microphones (existing + new)
|
||||
return .run { send in
|
||||
func audioPropertyAddress(
|
||||
_ selector: AudioObjectPropertySelector,
|
||||
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
|
||||
element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain
|
||||
) -> AudioObjectPropertyAddress {
|
||||
AudioObjectPropertyAddress(
|
||||
mSelector: selector,
|
||||
mScope: scope,
|
||||
mElement: element
|
||||
)
|
||||
}
|
||||
|
||||
await send(.modelDownload(.fetchModels))
|
||||
await send(.loadAvailableInputDevices)
|
||||
|
||||
// Listen for device connection/disconnection notifications
|
||||
// Using a simpler debounced approach with a single task
|
||||
var deviceUpdateTask: Task<Void, Never>?
|
||||
var audioHardwareObservers: [(AudioObjectPropertySelector, SettingsAudioPropertyListenerBlock)] = []
|
||||
|
||||
// Helper function to debounce device updates
|
||||
func debounceDeviceUpdate() {
|
||||
deviceUpdateTask?.cancel()
|
||||
deviceUpdateTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // 500ms
|
||||
if !Task.isCancelled {
|
||||
await send(.loadAvailableInputDevices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installAudioHardwareObserver(_ selector: AudioObjectPropertySelector) {
|
||||
let listener: SettingsAudioPropertyListenerBlock = { _, _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
var address = audioPropertyAddress(selector)
|
||||
let status = AudioObjectAddPropertyListenerBlock(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
DispatchQueue.main,
|
||||
listener
|
||||
)
|
||||
|
||||
if status == noErr {
|
||||
audioHardwareObservers.append((selector, listener))
|
||||
} else {
|
||||
settingsLogger.error("Failed to observe audio hardware selector \(selector): \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
let deviceConnectionObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasConnected"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
let deviceDisconnectionObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasDisconnected"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
let appDidBecomeActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
let wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||
forName: NSWorkspace.didWakeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
installAudioHardwareObserver(kAudioHardwarePropertyDefaultInputDevice)
|
||||
installAudioHardwareObserver(kAudioHardwarePropertyDevices)
|
||||
|
||||
// Be sure to clean up resources when the task is finished
|
||||
defer {
|
||||
deviceUpdateTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(deviceConnectionObserver)
|
||||
NotificationCenter.default.removeObserver(deviceDisconnectionObserver)
|
||||
NotificationCenter.default.removeObserver(appDidBecomeActiveObserver)
|
||||
NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver)
|
||||
|
||||
for (selector, listener) in audioHardwareObservers {
|
||||
var address = audioPropertyAddress(selector)
|
||||
let status = AudioObjectRemovePropertyListenerBlock(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
DispatchQueue.main,
|
||||
listener
|
||||
)
|
||||
if status != noErr {
|
||||
settingsLogger.error("Failed to remove audio hardware observer for selector \(selector): \(status)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for try await keyEvent in await keyEventMonitor.listenForKeyPress() {
|
||||
await send(.keyEvent(keyEvent))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
case .startSettingHotKey:
|
||||
beginCapture(.recording, state: &state)
|
||||
return .none
|
||||
|
||||
case .addWordRemoval:
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemovals.append(.init(pattern: ""))
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .updateWordRemoval(removal):
|
||||
state.$hexSettings.withLock {
|
||||
guard let index = $0.wordRemovals.firstIndex(where: { $0.id == removal.id }) else { return }
|
||||
$0.wordRemovals[index] = removal
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .removeWordRemoval(id):
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemovals.removeAll { $0.id == id }
|
||||
}
|
||||
return .none
|
||||
|
||||
case .addWordRemapping:
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemappings.append(.init(match: "", replacement: ""))
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .updateWordRemapping(remapping):
|
||||
state.$hexSettings.withLock {
|
||||
guard let index = $0.wordRemappings.firstIndex(where: { $0.id == remapping.id }) else { return }
|
||||
$0.wordRemappings[index] = remapping
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .removeWordRemapping(id):
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemappings.removeAll { $0.id == id }
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setRemappingScratchpadFocused(isFocused):
|
||||
state.$isRemappingScratchpadFocused.withLock { $0 = isFocused }
|
||||
return .none
|
||||
|
||||
case let .setLowercaseTranscripts(enabled):
|
||||
state.$hexSettings.withLock { $0.lowercaseTranscripts = enabled }
|
||||
return .none
|
||||
|
||||
case let .setRemovePunctuation(enabled):
|
||||
state.$hexSettings.withLock { $0.removePunctuation = enabled }
|
||||
return .none
|
||||
|
||||
case .startSettingPasteLastTranscriptHotkey:
|
||||
beginCapture(.pasteLastTranscript, state: &state)
|
||||
return .none
|
||||
|
||||
case .clearPasteLastTranscriptHotkey:
|
||||
state.$hexSettings.withLock { $0.pasteLastTranscriptHotkey = nil }
|
||||
return .none
|
||||
|
||||
case let .keyEvent(keyEvent):
|
||||
if state.isSettingPasteLastTranscriptHotkey {
|
||||
return handleCapture(keyEvent, for: .pasteLastTranscript, state: &state)
|
||||
}
|
||||
|
||||
guard state.isSettingHotKey else { return .none }
|
||||
return handleCapture(keyEvent, for: .recording, state: &state)
|
||||
|
||||
case let .toggleOpenOnLogin(enabled):
|
||||
state.$hexSettings.withLock { $0.openOnLogin = enabled }
|
||||
return .run { _ in
|
||||
if enabled {
|
||||
try? SMAppService.mainApp.register()
|
||||
} else {
|
||||
try? SMAppService.mainApp.unregister()
|
||||
}
|
||||
}
|
||||
|
||||
case let .toggleShowDockIcon(enabled):
|
||||
state.$hexSettings.withLock { $0.showDockIcon = enabled }
|
||||
return .run { _ in
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .updateAppMode, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
case let .togglePreventSystemSleep(enabled):
|
||||
state.$hexSettings.withLock { $0.preventSystemSleep = enabled }
|
||||
return .none
|
||||
|
||||
case let .setUseClipboardPaste(enabled):
|
||||
state.$hexSettings.withLock { $0.useClipboardPaste = enabled }
|
||||
return .none
|
||||
|
||||
case let .setCopyToClipboard(enabled):
|
||||
state.$hexSettings.withLock { $0.copyToClipboard = enabled }
|
||||
return .none
|
||||
|
||||
case let .setRecordingAudioBehavior(behavior):
|
||||
state.$hexSettings.withLock { $0.recordingAudioBehavior = behavior }
|
||||
return .none
|
||||
|
||||
case let .toggleSuperFastMode(enabled):
|
||||
state.$hexSettings.withLock { $0.superFastModeEnabled = enabled }
|
||||
return .run { _ in
|
||||
await recording.warmUpRecorder()
|
||||
}
|
||||
|
||||
case let .setDoubleTapLockEnabled(enabled):
|
||||
state.$hexSettings.withLock {
|
||||
$0.doubleTapLockEnabled = enabled
|
||||
if !enabled {
|
||||
$0.useDoubleTapOnly = false
|
||||
}
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setUseDoubleTapOnly(enabled):
|
||||
state.$hexSettings.withLock {
|
||||
$0.useDoubleTapOnly = enabled && $0.doubleTapLockEnabled
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setMinimumKeyTime(value):
|
||||
state.$hexSettings.withLock { $0.minimumKeyTime = value }
|
||||
return .none
|
||||
|
||||
case let .setOutputLanguage(language):
|
||||
state.$hexSettings.withLock { $0.outputLanguage = language }
|
||||
return .none
|
||||
|
||||
case let .setSelectedMicrophoneID(deviceID):
|
||||
state.$hexSettings.withLock { $0.selectedMicrophoneID = deviceID }
|
||||
return .run { _ in
|
||||
await recording.warmUpRecorder()
|
||||
}
|
||||
|
||||
case let .setSoundEffectsEnabled(enabled):
|
||||
state.$hexSettings.withLock { $0.soundEffectsEnabled = enabled }
|
||||
return .run { _ in
|
||||
await soundEffects.setEnabled(enabled)
|
||||
}
|
||||
|
||||
case let .setSoundEffectsVolume(volume):
|
||||
state.$hexSettings.withLock { $0.soundEffectsVolume = volume }
|
||||
return .none
|
||||
|
||||
// Permission requests
|
||||
case .requestMicrophone:
|
||||
settingsLogger.info("User requested microphone permission from settings")
|
||||
return .none
|
||||
|
||||
case .requestAccessibility:
|
||||
settingsLogger.info("User requested accessibility permission from settings")
|
||||
return .none
|
||||
|
||||
case .requestInputMonitoring:
|
||||
settingsLogger.info("User requested input monitoring permission from settings")
|
||||
return .none
|
||||
|
||||
// Model Management
|
||||
case .modelDownload:
|
||||
return .none
|
||||
|
||||
// Microphone device selection
|
||||
case .loadAvailableInputDevices:
|
||||
return .run { send in
|
||||
let devices = await recording.getAvailableInputDevices()
|
||||
let defaultName = await recording.getDefaultInputDeviceName()
|
||||
await send(.availableInputDevicesLoaded(devices, defaultName))
|
||||
}
|
||||
|
||||
case let .availableInputDevicesLoaded(devices, defaultName):
|
||||
if let selectedMicrophoneID = state.hexSettings.selectedMicrophoneID,
|
||||
let device = devices.first(where: { $0.legacyID == selectedMicrophoneID }) {
|
||||
state.availableInputDevices = devices
|
||||
state.defaultInputDeviceName = defaultName
|
||||
return .send(.setSelectedMicrophoneID(device.id))
|
||||
}
|
||||
state.availableInputDevices = devices
|
||||
state.defaultInputDeviceName = defaultName
|
||||
return .none
|
||||
|
||||
case let .toggleSaveTranscriptionHistory(enabled):
|
||||
state.$hexSettings.withLock { $0.saveTranscriptionHistory = enabled }
|
||||
|
||||
// If disabling history, delete all existing entries
|
||||
if !enabled {
|
||||
let transcripts = state.transcriptionHistory.history
|
||||
|
||||
// Clear the history
|
||||
state.$transcriptionHistory.withLock { history in
|
||||
history.history.removeAll()
|
||||
}
|
||||
|
||||
return deleteAudioEffect(for: transcripts)
|
||||
}
|
||||
|
||||
return .none
|
||||
|
||||
case let .setMaxHistoryEntries(maxHistoryEntries):
|
||||
state.$hexSettings.withLock { $0.maxHistoryEntries = maxHistoryEntries }
|
||||
return .none
|
||||
|
||||
case let .setModifierSide(kind, side):
|
||||
guard state.hexSettings.hotkey.key == nil else { return .none }
|
||||
state.$hexSettings.withLock {
|
||||
$0.hotkey.modifiers = $0.hotkey.modifiers.setting(kind: kind, to: side)
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setWordRemovalsEnabled(enabled):
|
||||
state.$hexSettings.withLock { $0.wordRemovalsEnabled = enabled }
|
||||
return .none
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
let microphonePermission: PermissionStatus
|
||||
let accessibilityPermission: PermissionStatus
|
||||
let inputMonitoringPermission: PermissionStatus
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if microphonePermission != .granted
|
||||
|| accessibilityPermission != .granted
|
||||
|| inputMonitoringPermission != .granted {
|
||||
PermissionsSectionView(
|
||||
store: store,
|
||||
microphonePermission: microphonePermission,
|
||||
accessibilityPermission: accessibilityPermission,
|
||||
inputMonitoringPermission: inputMonitoringPermission
|
||||
)
|
||||
}
|
||||
|
||||
ModelSectionView(store: store, shouldFlash: store.shouldFlashModelSection)
|
||||
// Only show language picker for WhisperKit models (not Parakeet)
|
||||
if ParakeetModel(rawValue: store.hexSettings.selectedModel) == nil {
|
||||
LanguageSectionView(store: store)
|
||||
}
|
||||
|
||||
HotKeySectionView(store: store)
|
||||
|
||||
if microphonePermission == .granted {
|
||||
MicrophoneSelectionSectionView(store: store)
|
||||
}
|
||||
|
||||
SoundSectionView(store: store)
|
||||
GeneralSectionView(store: store)
|
||||
HistorySectionView(store: store)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task {
|
||||
await store.send(.task).finish()
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared Styles
|
||||
|
||||
extension Text {
|
||||
/// Applies caption font with secondary color, commonly used for helper/description text in settings.
|
||||
func settingsCaption() -> some View {
|
||||
self.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct SoundSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
let sliderBinding = Binding<Double>(
|
||||
get: { volumePercentage(for: store.hexSettings.soundEffectsVolume) },
|
||||
set: { store.send(.setSoundEffectsVolume(actualVolume(fromPercentage: $0))) }
|
||||
)
|
||||
|
||||
return Section {
|
||||
Label {
|
||||
Toggle(
|
||||
"Sound Effects",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.soundEffectsEnabled },
|
||||
set: { store.send(.setSoundEffectsEnabled($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Volume")
|
||||
Spacer()
|
||||
Text(formattedVolume(for: store.hexSettings.soundEffectsVolume))
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Slider(value: sliderBinding, in: 0...1)
|
||||
.disabled(!store.hexSettings.soundEffectsEnabled)
|
||||
}
|
||||
} header: {
|
||||
Text("Sound")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedVolume(for actualVolume: Double) -> String {
|
||||
let percent = volumePercentage(for: actualVolume)
|
||||
return "\(Int(round(percent * 100)))%"
|
||||
}
|
||||
|
||||
private func volumePercentage(for actualVolume: Double) -> Double {
|
||||
guard HexSettings.baseSoundEffectsVolume > 0 else { return 0 }
|
||||
let ratio = actualVolume / HexSettings.baseSoundEffectsVolume
|
||||
return max(0, min(1, ratio))
|
||||
}
|
||||
|
||||
private func actualVolume(fromPercentage percentage: Double) -> Double {
|
||||
let clampedPercentage = max(0, min(1, percentage))
|
||||
return clampedPercentage * HexSettings.baseSoundEffectsVolume
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
//
|
||||
// TranscriptionFeature.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/24/25.
|
||||
//
|
||||
|
||||
import ComposableArchitecture
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
import WhisperKit
|
||||
|
||||
private let transcriptionFeatureLogger = HexLog.transcription
|
||||
|
||||
@Reducer
|
||||
struct TranscriptionFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var isRecording: Bool = false
|
||||
var isTranscribing: Bool = false
|
||||
var isPrewarming: Bool = false
|
||||
var error: String?
|
||||
var recordingStartTime: Date?
|
||||
var meter: Meter = .init(averagePower: 0, peakPower: 0)
|
||||
var sourceAppBundleID: String?
|
||||
var sourceAppName: String?
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case task
|
||||
case audioLevelUpdated(Meter)
|
||||
|
||||
// Hotkey actions
|
||||
case hotKeyPressed
|
||||
case hotKeyReleased
|
||||
|
||||
// Recording flow
|
||||
case startRecording
|
||||
case stopRecording
|
||||
|
||||
// Cancel/discard flow
|
||||
case cancel // Explicit cancellation with sound
|
||||
case discard // Silent discard (too short/accidental)
|
||||
|
||||
// Transcription result flow
|
||||
case transcriptionResult(String, URL, TimeInterval)
|
||||
case transcriptionError(Error, URL?)
|
||||
|
||||
// Model availability
|
||||
case modelMissing
|
||||
}
|
||||
|
||||
enum CancelID {
|
||||
case metering
|
||||
case recordingStart
|
||||
case recordingCleanup
|
||||
case transcription
|
||||
}
|
||||
|
||||
@Dependency(\.transcription) var transcription
|
||||
@Dependency(\.recording) var recording
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
@Dependency(\.keyEventMonitor) var keyEventMonitor
|
||||
@Dependency(\.soundEffects) var soundEffect
|
||||
@Dependency(\.sleepManagement) var sleepManagement
|
||||
@Dependency(\.date.now) var now
|
||||
@Dependency(\.transcriptPersistence) var transcriptPersistence
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
// MARK: - Lifecycle / Setup
|
||||
|
||||
case .task:
|
||||
// Starts two concurrent effects:
|
||||
// 1) Observing audio meter
|
||||
// 2) Monitoring hot key events
|
||||
// 3) Priming the recorder for instant startup
|
||||
return .merge(
|
||||
startMeteringEffect(),
|
||||
startHotKeyMonitoringEffect(),
|
||||
warmUpRecorderEffect()
|
||||
)
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
case let .audioLevelUpdated(meter):
|
||||
state.meter = meter
|
||||
return .none
|
||||
|
||||
// MARK: - HotKey Flow
|
||||
|
||||
case .hotKeyPressed:
|
||||
// If we're transcribing, send a cancel first. Otherwise start recording immediately.
|
||||
// We'll decide later (on release) whether to keep or discard the recording.
|
||||
return handleHotKeyPressed(isTranscribing: state.isTranscribing)
|
||||
|
||||
case .hotKeyReleased:
|
||||
// If we're currently recording, then stop. Otherwise, just cancel
|
||||
// the delayed "startRecording" effect if we never actually started.
|
||||
return handleHotKeyReleased(isRecording: state.isRecording)
|
||||
|
||||
// MARK: - Recording Flow
|
||||
|
||||
case .startRecording:
|
||||
return handleStartRecording(&state)
|
||||
|
||||
case .stopRecording:
|
||||
return handleStopRecording(&state)
|
||||
|
||||
// MARK: - Transcription Results
|
||||
|
||||
case let .transcriptionResult(result, audioURL, duration):
|
||||
return handleTranscriptionResult(&state, result: result, audioURL: audioURL, duration: duration)
|
||||
|
||||
case let .transcriptionError(error, audioURL):
|
||||
return handleTranscriptionError(&state, error: error, audioURL: audioURL)
|
||||
|
||||
case .modelMissing:
|
||||
return .none
|
||||
|
||||
// MARK: - Cancel/Discard Flow
|
||||
|
||||
case .cancel:
|
||||
// Only cancel if we're in the middle of recording, transcribing, or post-processing
|
||||
guard state.isRecording || state.isTranscribing else {
|
||||
return .none
|
||||
}
|
||||
return handleCancel(&state)
|
||||
|
||||
case .discard:
|
||||
// Silent discard for quick/accidental recordings
|
||||
guard state.isRecording else {
|
||||
return .none
|
||||
}
|
||||
return handleDiscard(&state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Effects: Metering & HotKey
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
/// Effect to begin observing the audio meter.
|
||||
func startMeteringEffect() -> Effect<Action> {
|
||||
.run { send in
|
||||
for await meter in await recording.observeAudioLevel() {
|
||||
await send(.audioLevelUpdated(meter))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.metering, cancelInFlight: true)
|
||||
}
|
||||
|
||||
/// Effect to start monitoring hotkey events through the `keyEventMonitor`.
|
||||
func startHotKeyMonitoringEffect() -> Effect<Action> {
|
||||
.run { send in
|
||||
var hotKeyProcessor: HotKeyProcessor = .init(hotkey: HotKey(key: nil, modifiers: [.option]))
|
||||
@Shared(.isSettingHotKey) var isSettingHotKey: Bool
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
// Handle incoming input events (keyboard and mouse)
|
||||
let token = keyEventMonitor.handleInputEvent { inputEvent in
|
||||
// Skip if the user is currently setting a hotkey
|
||||
if isSettingHotKey {
|
||||
return false
|
||||
}
|
||||
|
||||
// Always keep hotKeyProcessor in sync with current user hotkey preference
|
||||
hotKeyProcessor.hotkey = hexSettings.hotkey
|
||||
let useDoubleTapOnly = hexSettings.doubleTapLockEnabled && hexSettings.useDoubleTapOnly
|
||||
hotKeyProcessor.doubleTapLockEnabled = hexSettings.doubleTapLockEnabled
|
||||
hotKeyProcessor.useDoubleTapOnly = useDoubleTapOnly
|
||||
hotKeyProcessor.minimumKeyTime = hexSettings.minimumKeyTime
|
||||
|
||||
switch inputEvent {
|
||||
case .keyboard(let keyEvent):
|
||||
// If Escape is pressed with no modifiers while idle, let's treat that as `cancel`.
|
||||
if keyEvent.key == .escape, keyEvent.modifiers.isEmpty,
|
||||
hotKeyProcessor.state == .idle
|
||||
{
|
||||
Task { await send(.cancel) }
|
||||
return false
|
||||
}
|
||||
|
||||
// Process the key event
|
||||
switch hotKeyProcessor.process(keyEvent: keyEvent) {
|
||||
case .startRecording:
|
||||
Task { await send(.hotKeyPressed) }
|
||||
// If the hotkey is purely modifiers, return false to keep it from interfering with normal usage
|
||||
// But if useDoubleTapOnly is true, always intercept the key
|
||||
return useDoubleTapOnly || keyEvent.key != nil
|
||||
|
||||
case .stopRecording:
|
||||
Task { await send(.hotKeyReleased) }
|
||||
return false // or `true` if you want to intercept
|
||||
|
||||
case .cancel:
|
||||
Task { await send(.cancel) }
|
||||
return true
|
||||
|
||||
case .discard:
|
||||
Task { await send(.discard) }
|
||||
return false // Don't intercept - let the key chord reach other apps
|
||||
|
||||
case .none:
|
||||
// If we detect repeated same chord, maybe intercept.
|
||||
if let pressedKey = keyEvent.key,
|
||||
pressedKey == hotKeyProcessor.hotkey.key,
|
||||
keyEvent.modifiers == hotKeyProcessor.hotkey.modifiers
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
case .mouseClick:
|
||||
// Process mouse click - for modifier-only hotkeys, this may cancel/discard
|
||||
switch hotKeyProcessor.processMouseClick() {
|
||||
case .cancel:
|
||||
Task { await send(.cancel) }
|
||||
return false // Don't intercept the click itself
|
||||
case .discard:
|
||||
Task { await send(.discard) }
|
||||
return false // Don't intercept the click itself
|
||||
case .startRecording, .stopRecording, .none:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defer { token.cancel() }
|
||||
|
||||
await withTaskCancellationHandler {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
} onCancel: {
|
||||
token.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func warmUpRecorderEffect() -> Effect<Action> {
|
||||
.run { _ in
|
||||
await recording.warmUpRecorder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HotKey Press/Release Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleHotKeyPressed(isTranscribing: Bool) -> Effect<Action> {
|
||||
// If already transcribing, cancel first. Otherwise start recording immediately.
|
||||
guard isTranscribing else { return .send(.startRecording) }
|
||||
return .concatenate(
|
||||
.send(.cancel),
|
||||
.send(.startRecording)
|
||||
)
|
||||
}
|
||||
|
||||
func handleHotKeyReleased(isRecording: Bool) -> Effect<Action> {
|
||||
// Always stop recording when hotkey is released
|
||||
return isRecording ? .send(.stopRecording) : .none
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recording Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleStartRecording(_ state: inout State) -> Effect<Action> {
|
||||
guard state.modelBootstrapState.isModelReady else {
|
||||
return .merge(
|
||||
.send(.modelMissing),
|
||||
.run { _ in soundEffect.play(.cancel) }
|
||||
)
|
||||
}
|
||||
state.isRecording = true
|
||||
let startTime = now
|
||||
state.recordingStartTime = startTime
|
||||
|
||||
// Capture the active application
|
||||
if let activeApp = NSWorkspace.shared.frontmostApplication {
|
||||
state.sourceAppBundleID = activeApp.bundleIdentifier
|
||||
state.sourceAppName = activeApp.localizedName
|
||||
}
|
||||
transcriptionFeatureLogger.notice("Recording started at \(startTime.ISO8601Format())")
|
||||
|
||||
// Prevent system sleep during recording
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingCleanup),
|
||||
.run { [sleepManagement, preventSleep = state.hexSettings.preventSystemSleep] _ in
|
||||
// Play sound immediately for instant feedback
|
||||
soundEffect.play(.startRecording)
|
||||
|
||||
if preventSleep {
|
||||
await sleepManagement.preventSleep(reason: "Hex Voice Recording")
|
||||
}
|
||||
guard !Task.isCancelled else {
|
||||
if preventSleep {
|
||||
await sleepManagement.allowSleep()
|
||||
}
|
||||
return
|
||||
}
|
||||
await recording.startRecording()
|
||||
}
|
||||
.cancellable(id: CancelID.recordingStart, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
|
||||
func handleStopRecording(_ state: inout State) -> Effect<Action> {
|
||||
state.isRecording = false
|
||||
|
||||
let stopTime = now
|
||||
let startTime = state.recordingStartTime
|
||||
let duration = startTime.map { stopTime.timeIntervalSince($0) } ?? 0
|
||||
|
||||
let decision = RecordingDecisionEngine.decide(
|
||||
.init(
|
||||
hotkey: state.hexSettings.hotkey,
|
||||
minimumKeyTime: state.hexSettings.minimumKeyTime,
|
||||
recordingStartTime: state.recordingStartTime,
|
||||
currentTime: stopTime
|
||||
)
|
||||
)
|
||||
|
||||
let startStamp = startTime?.ISO8601Format() ?? "nil"
|
||||
let stopStamp = stopTime.ISO8601Format()
|
||||
let minimumKeyTime = state.hexSettings.minimumKeyTime
|
||||
let hotkeyHasKey = state.hexSettings.hotkey.key != nil
|
||||
transcriptionFeatureLogger.notice(
|
||||
"Recording stopped duration=\(String(format: "%.3f", duration))s start=\(startStamp) stop=\(stopStamp) decision=\(String(describing: decision)) minimumKeyTime=\(String(format: "%.2f", minimumKeyTime)) hotkeyHasKey=\(hotkeyHasKey)"
|
||||
)
|
||||
|
||||
guard decision == .proceedToTranscription else {
|
||||
// If the user recorded for less than minimumKeyTime and the hotkey is modifier-only,
|
||||
// discard the audio to avoid accidental triggers.
|
||||
transcriptionFeatureLogger.notice("Discarding short recording per decision \(String(describing: decision))")
|
||||
return handleDiscard(&state)
|
||||
}
|
||||
|
||||
let model = state.hexSettings.selectedModel
|
||||
guard !model.isEmpty else {
|
||||
// Defense-in-depth: handleStartRecording already blocks recording when the
|
||||
// bootstrap state says no model is ready, but settings can change while a
|
||||
// recording is in flight (or the in-memory bootstrap default can race a
|
||||
// cold launch). Never hand an empty model name to the transcriber: it
|
||||
// silently produces nothing (or junk like "[BLANK_AUDIO]").
|
||||
transcriptionFeatureLogger.error("Recording stopped with no transcription model selected; discarding audio")
|
||||
return .merge(
|
||||
handleDiscard(&state),
|
||||
.send(.modelMissing)
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise, proceed to transcription
|
||||
state.isTranscribing = true
|
||||
state.error = nil
|
||||
let language = state.hexSettings.outputLanguage
|
||||
|
||||
state.isPrewarming = true
|
||||
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] send in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
|
||||
var audioURL: URL?
|
||||
defer {
|
||||
if let audioURL {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
do {
|
||||
let stopResult = await recording.stopRecording()
|
||||
let capturedURL: URL
|
||||
switch stopResult {
|
||||
case let .captured(url):
|
||||
capturedURL = url
|
||||
case .ignored(.staleSession):
|
||||
transcriptionFeatureLogger.notice("Ignoring transcription stop superseded by a newer recording session")
|
||||
return
|
||||
case .ignored(.noActiveRecording):
|
||||
transcriptionFeatureLogger.error("Recording stopped without captured audio")
|
||||
await send(.transcriptionError(RecordingFailure.noCapturedAudio, nil))
|
||||
return
|
||||
case let .failed(error):
|
||||
transcriptionFeatureLogger.error("Recording stop failed: \(error.localizedDescription)")
|
||||
await send(.transcriptionError(error, nil))
|
||||
return
|
||||
}
|
||||
audioURL = capturedURL
|
||||
guard !Task.isCancelled else { return }
|
||||
soundEffect.play(.stopRecording)
|
||||
|
||||
// Create transcription options with the selected language
|
||||
// Note: cap concurrency to avoid audio I/O overloads on some Macs
|
||||
let decodeOptions = DecodingOptions(
|
||||
language: language,
|
||||
detectLanguage: language == nil, // Only auto-detect if no language specified
|
||||
chunkingStrategy: .vad,
|
||||
)
|
||||
|
||||
let result = try await transcription.transcribe(capturedURL, model, decodeOptions) { _ in }
|
||||
|
||||
transcriptionFeatureLogger.notice("Transcribed audio from \(capturedURL.lastPathComponent) to text length \(result.count)")
|
||||
audioURL = nil
|
||||
await send(.transcriptionResult(result, capturedURL, duration))
|
||||
} catch {
|
||||
transcriptionFeatureLogger.error("Transcription failed: \(error.localizedDescription)")
|
||||
await send(.transcriptionError(error, nil))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.transcription)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transcription Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleTranscriptionResult(
|
||||
_ state: inout State,
|
||||
result: String,
|
||||
audioURL: URL,
|
||||
duration: TimeInterval
|
||||
) -> Effect<Action> {
|
||||
state.isTranscribing = false
|
||||
state.isPrewarming = false
|
||||
|
||||
// Check for force quit command (emergency escape hatch)
|
||||
if ForceQuitCommandDetector.matches(result) {
|
||||
transcriptionFeatureLogger.fault("Force quit voice command recognized; terminating Hex.")
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
await MainActor.run {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If empty text, nothing else to do
|
||||
guard !result.isEmpty else {
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
|
||||
transcriptionFeatureLogger.info("Raw transcription: '\(result, privacy: .private)'")
|
||||
let remappings = state.hexSettings.wordRemappings
|
||||
let removalsEnabled = state.hexSettings.wordRemovalsEnabled
|
||||
let removals = state.hexSettings.wordRemovals
|
||||
let modifiedResult: String
|
||||
if state.isRemappingScratchpadFocused {
|
||||
modifiedResult = result
|
||||
transcriptionFeatureLogger.info("Scratchpad focused; skipping word modifications")
|
||||
} else {
|
||||
var output = result
|
||||
if removalsEnabled {
|
||||
let removedResult = WordRemovalApplier.apply(output, removals: removals)
|
||||
if removedResult != output {
|
||||
let enabledRemovalCount = removals.filter(\.isEnabled).count
|
||||
transcriptionFeatureLogger.info("Applied \(enabledRemovalCount) word removal(s)")
|
||||
}
|
||||
output = removedResult
|
||||
}
|
||||
let remappedResult = WordRemappingApplier.apply(output, remappings: remappings)
|
||||
if remappedResult != output {
|
||||
transcriptionFeatureLogger.info("Applied \(remappings.count) word remapping(s)")
|
||||
}
|
||||
let formattedResult = TranscriptFormattingApplier.apply(
|
||||
remappedResult,
|
||||
lowercase: state.hexSettings.lowercaseTranscripts,
|
||||
removePunctuation: state.hexSettings.removePunctuation
|
||||
)
|
||||
if formattedResult != remappedResult {
|
||||
transcriptionFeatureLogger.info("Applied paste formatting")
|
||||
}
|
||||
modifiedResult = formattedResult
|
||||
}
|
||||
|
||||
guard !modifiedResult.isEmpty else {
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
|
||||
let sourceAppBundleID = state.sourceAppBundleID
|
||||
let sourceAppName = state.sourceAppName
|
||||
let transcriptionHistory = state.$transcriptionHistory
|
||||
|
||||
return .run { send in
|
||||
do {
|
||||
try await finalizeRecordingAndStoreTranscript(
|
||||
result: modifiedResult,
|
||||
duration: duration,
|
||||
sourceAppBundleID: sourceAppBundleID,
|
||||
sourceAppName: sourceAppName,
|
||||
audioURL: audioURL,
|
||||
transcriptionHistory: transcriptionHistory
|
||||
)
|
||||
} catch {
|
||||
await send(.transcriptionError(error, audioURL))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.transcription)
|
||||
}
|
||||
|
||||
func handleTranscriptionError(
|
||||
_ state: inout State,
|
||||
error: Error,
|
||||
audioURL: URL?
|
||||
) -> Effect<Action> {
|
||||
state.isTranscribing = false
|
||||
state.isPrewarming = false
|
||||
state.error = error.localizedDescription
|
||||
|
||||
if let audioURL {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
/// Move file to permanent location, create a transcript record, paste text, and play sound.
|
||||
func finalizeRecordingAndStoreTranscript(
|
||||
result: String,
|
||||
duration: TimeInterval,
|
||||
sourceAppBundleID: String?,
|
||||
sourceAppName: String?,
|
||||
audioURL: URL,
|
||||
transcriptionHistory: Shared<TranscriptionHistory>
|
||||
) async throws {
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
if hexSettings.saveTranscriptionHistory {
|
||||
let transcript = try await transcriptPersistence.save(
|
||||
result,
|
||||
audioURL,
|
||||
duration,
|
||||
sourceAppBundleID,
|
||||
sourceAppName
|
||||
)
|
||||
|
||||
transcriptionHistory.withLock { history in
|
||||
history.history.insert(transcript, at: 0)
|
||||
|
||||
if let maxEntries = hexSettings.maxHistoryEntries, maxEntries > 0 {
|
||||
while history.history.count > maxEntries {
|
||||
if let removedTranscript = history.history.popLast() {
|
||||
Task {
|
||||
try? await transcriptPersistence.deleteAudio(removedTranscript)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
|
||||
await pasteboard.paste(result)
|
||||
soundEffect.play(.pasteTranscript)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cancel/Discard Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleCancel(_ state: inout State) -> Effect<Action> {
|
||||
let wasRecording = state.isRecording
|
||||
state.isTranscribing = false
|
||||
state.isRecording = false
|
||||
state.isPrewarming = false
|
||||
|
||||
return .merge(
|
||||
.cancel(id: CancelID.transcription),
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] _ in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
guard wasRecording else {
|
||||
soundEffect.play(.cancel)
|
||||
return
|
||||
}
|
||||
// Stop the recording to release microphone access
|
||||
let result = await recording.stopRecording()
|
||||
if case let .captured(url) = result {
|
||||
FileManager.default.removeItemIfExists(at: url)
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
soundEffect.play(.cancel)
|
||||
}
|
||||
.cancellable(id: CancelID.recordingCleanup, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
|
||||
func handleDiscard(_ state: inout State) -> Effect<Action> {
|
||||
state.isRecording = false
|
||||
state.isPrewarming = false
|
||||
|
||||
// Silently discard - no sound effect
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] _ in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
let result = await recording.stopRecording()
|
||||
if case let .captured(url) = result {
|
||||
FileManager.default.removeItemIfExists(at: url)
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
.cancellable(id: CancelID.recordingCleanup, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
struct TranscriptionView: View {
|
||||
@Bindable var store: StoreOf<TranscriptionFeature>
|
||||
@ObserveInjection var inject
|
||||
|
||||
var status: TranscriptionIndicatorView.Status {
|
||||
if store.isTranscribing {
|
||||
return .transcribing
|
||||
} else if store.isRecording {
|
||||
return .recording
|
||||
} else if store.isPrewarming {
|
||||
return .prewarming
|
||||
} else {
|
||||
return .hidden
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TranscriptionIndicatorView(
|
||||
status: status,
|
||||
meter: store.meter
|
||||
)
|
||||
.task {
|
||||
await store.send(.task).finish()
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Force Quit Command
|
||||
|
||||
private enum ForceQuitCommandDetector {
|
||||
static func matches(_ text: String) -> Bool {
|
||||
let normalized = normalize(text)
|
||||
return normalized == "force quit hex now" || normalized == "force quit hex"
|
||||
}
|
||||
|
||||
private static func normalize(_ text: String) -> String {
|
||||
text
|
||||
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// HexCapsuleView.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/25/25.
|
||||
|
||||
import AppKit
|
||||
import Inject
|
||||
import Pow
|
||||
import SwiftUI
|
||||
|
||||
struct TranscriptionIndicatorView: View {
|
||||
@ObserveInjection var inject
|
||||
|
||||
enum Status {
|
||||
case hidden
|
||||
case optionKeyPressed
|
||||
case recording
|
||||
case transcribing
|
||||
case prewarming
|
||||
}
|
||||
|
||||
var status: Status
|
||||
var meter: Meter
|
||||
|
||||
let transcribeBaseColor: Color = .blue
|
||||
private var backgroundColor: Color {
|
||||
switch status {
|
||||
case .hidden: return Color.clear
|
||||
case .optionKeyPressed: return Color.black
|
||||
case .recording:
|
||||
return mixedColor(mixedNSColor(.red, with: .black, by: 0.5), with: .red, by: meter.averagePower * 3)
|
||||
case .transcribing: return mixedColor(.blue, with: .black, by: 0.5)
|
||||
case .prewarming: return mixedColor(.blue, with: .black, by: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
private var strokeColor: Color {
|
||||
switch status {
|
||||
case .hidden: return Color.clear
|
||||
case .optionKeyPressed: return Color.black
|
||||
case .recording: return mixedColor(.red, with: .white, by: 0.1).opacity(0.6)
|
||||
case .transcribing: return mixedColor(.blue, with: .white, by: 0.1).opacity(0.6)
|
||||
case .prewarming: return mixedColor(.blue, with: .white, by: 0.1).opacity(0.6)
|
||||
}
|
||||
}
|
||||
|
||||
private func mixedColor(_ color: NSColor, with otherColor: NSColor, by fraction: Double) -> Color {
|
||||
Color(nsColor: mixedNSColor(color, with: otherColor, by: fraction))
|
||||
}
|
||||
|
||||
private func mixedNSColor(_ color: NSColor, with otherColor: NSColor, by fraction: Double) -> NSColor {
|
||||
let clampedFraction = min(max(fraction, 0), 1)
|
||||
return color.blended(withFraction: clampedFraction, of: otherColor) ?? color
|
||||
}
|
||||
|
||||
private var innerShadowColor: Color {
|
||||
switch status {
|
||||
case .hidden: return Color.clear
|
||||
case .optionKeyPressed: return Color.clear
|
||||
case .recording: return Color.red
|
||||
case .transcribing: return transcribeBaseColor
|
||||
case .prewarming: return transcribeBaseColor
|
||||
}
|
||||
}
|
||||
|
||||
private let cornerRadius: CGFloat = 8
|
||||
private let baseWidth: CGFloat = 16
|
||||
private let expandedWidth: CGFloat = 56
|
||||
|
||||
var isHidden: Bool {
|
||||
status == .hidden
|
||||
}
|
||||
|
||||
@State var transcribeEffect = 0
|
||||
|
||||
var body: some View {
|
||||
let averagePower = min(1, meter.averagePower * 3)
|
||||
let peakPower = min(1, meter.peakPower * 3)
|
||||
ZStack {
|
||||
Capsule()
|
||||
.fill(backgroundColor.shadow(.inner(color: innerShadowColor, radius: 4)))
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(strokeColor, lineWidth: 1)
|
||||
.blendMode(.screen)
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.red.opacity(status == .recording ? (averagePower < 0.1 ? averagePower / 0.1 : 1) : 0))
|
||||
.blur(radius: 2)
|
||||
.blendMode(.screen)
|
||||
.padding(6)
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.white.opacity(status == .recording ? (averagePower < 0.1 ? averagePower / 0.1 : 0.5) : 0))
|
||||
.blur(radius: 1)
|
||||
.blendMode(.screen)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(7)
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
GeometryReader { proxy in
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.red.opacity(status == .recording ? (peakPower < 0.1 ? (peakPower / 0.1) * 0.5 : 0.5) : 0))
|
||||
.frame(width: max(proxy.size.width * (peakPower + 0.6), 0), height: proxy.size.height, alignment: .center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.blur(radius: 4)
|
||||
.blendMode(.screen)
|
||||
}.padding(6)
|
||||
}
|
||||
.cornerRadius(cornerRadius)
|
||||
.shadow(
|
||||
color: status == .recording ? .red.opacity(averagePower) : .red.opacity(0),
|
||||
radius: 4
|
||||
)
|
||||
.shadow(
|
||||
color: status == .recording ? .red.opacity(averagePower * 0.5) : .red.opacity(0),
|
||||
radius: 8
|
||||
)
|
||||
.animation(.interactiveSpring(), value: meter)
|
||||
.frame(
|
||||
width: status == .recording ? expandedWidth : baseWidth,
|
||||
height: baseWidth
|
||||
)
|
||||
.opacity(status == .hidden ? 0 : 1)
|
||||
.scaleEffect(status == .hidden ? 0.0 : 1)
|
||||
.blur(radius: status == .hidden ? 4 : 0)
|
||||
.animation(.bouncy(duration: 0.3), value: status)
|
||||
.changeEffect(.glow(color: .red.opacity(0.5), radius: 8), value: status)
|
||||
.changeEffect(.shine(angle: .degrees(0), duration: 0.6), value: transcribeEffect)
|
||||
.compositingGroup()
|
||||
.task(id: status == .transcribing) {
|
||||
while status == .transcribing, !Task.isCancelled {
|
||||
transcribeEffect += 1
|
||||
try? await Task.sleep(for: .seconds(0.25))
|
||||
}
|
||||
}
|
||||
|
||||
// Show tooltip when prewarming
|
||||
if status == .prewarming {
|
||||
VStack(spacing: 4) {
|
||||
Text("Model prewarming...")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.black.opacity(0.8))
|
||||
)
|
||||
}
|
||||
.offset(y: -24)
|
||||
.transition(.opacity)
|
||||
.zIndex(2)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("HEX") {
|
||||
VStack(spacing: 8) {
|
||||
TranscriptionIndicatorView(status: .hidden, meter: .init(averagePower: 0, peakPower: 0))
|
||||
TranscriptionIndicatorView(status: .optionKeyPressed, meter: .init(averagePower: 0, peakPower: 0))
|
||||
TranscriptionIndicatorView(status: .recording, meter: .init(averagePower: 0.5, peakPower: 0.5))
|
||||
TranscriptionIndicatorView(status: .transcribing, meter: .init(averagePower: 0, peakPower: 0))
|
||||
TranscriptionIndicatorView(status: .prewarming, meter: .init(averagePower: 0, peakPower: 0))
|
||||
}
|
||||
.padding(40)
|
||||
}
|
||||
Reference in New Issue
Block a user