chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+322
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
}
@@ -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()
}
}
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+61
View File
@@ -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)
}